diff --git a/src/blocks/VideoBlock.ts b/src/blocks/VideoBlock.ts index 8af9f7e..dd8d1a9 100644 --- a/src/blocks/VideoBlock.ts +++ b/src/blocks/VideoBlock.ts @@ -1,5 +1,14 @@ import type { Block } from 'payload' +/** + * VideoBlock + * + * Erweiterter Video-Block mit Unterstützung für: + * - YouTube/Vimeo Embeds + * - Video-Uploads + * - Video-Bibliothek (Videos Collection) + * - Externe Video-URLs + */ export const VideoBlock: Block = { slug: 'video-block', labels: { @@ -7,13 +16,68 @@ export const VideoBlock: Block = { plural: 'Videos', }, fields: [ + // === QUELLE === + { + name: 'sourceType', + type: 'select', + required: true, + defaultValue: 'embed', + label: 'Video-Quelle', + options: [ + { label: 'YouTube/Vimeo URL', value: 'embed' }, + { label: 'Video hochladen', value: 'upload' }, + { label: 'Aus Video-Bibliothek', value: 'library' }, + { label: 'Externe URL', value: 'external' }, + ], + admin: { + description: 'Woher soll das Video eingebunden werden?', + }, + }, + + // Video aus Bibliothek + { + name: 'videoFromLibrary', + type: 'relationship', + relationTo: 'videos', + label: 'Video auswählen', + admin: { + description: 'Video aus der Video-Bibliothek auswählen', + condition: (_, siblingData) => siblingData?.sourceType === 'library', + }, + }, + + // YouTube/Vimeo oder externe URL { name: 'videoUrl', type: 'text', - required: true, label: 'Video-URL', admin: { - description: 'YouTube oder Vimeo URL', + description: 'YouTube, Vimeo oder externe Video-URL', + condition: (_, siblingData) => + siblingData?.sourceType === 'embed' || siblingData?.sourceType === 'external', + }, + }, + + // Hochgeladenes Video + { + name: 'videoFile', + type: 'upload', + relationTo: 'media', + label: 'Video-Datei', + admin: { + description: 'MP4, WebM oder andere Video-Dateien hochladen', + condition: (_, siblingData) => siblingData?.sourceType === 'upload', + }, + }, + + // === DARSTELLUNG === + { + name: 'thumbnail', + type: 'upload', + relationTo: 'media', + label: 'Vorschaubild', + admin: { + description: 'Eigenes Thumbnail (optional, bei YouTube wird automatisch eines verwendet)', }, }, { @@ -21,6 +85,9 @@ export const VideoBlock: Block = { type: 'text', label: 'Beschriftung', localized: true, + admin: { + description: 'Bildunterschrift unter dem Video', + }, }, { name: 'aspectRatio', @@ -28,9 +95,174 @@ export const VideoBlock: Block = { defaultValue: '16:9', label: 'Seitenverhältnis', options: [ - { label: '16:9', value: '16:9' }, + { label: '16:9 (Standard)', value: '16:9' }, { label: '4:3', value: '4:3' }, - { label: '1:1', value: '1:1' }, + { label: '1:1 (Quadrat)', value: '1:1' }, + { label: '9:16 (Vertikal)', value: '9:16' }, + { label: '21:9 (Ultrawide)', value: '21:9' }, + ], + }, + { + name: 'size', + type: 'select', + defaultValue: 'full', + label: 'Größe', + options: [ + { label: 'Volle Breite', value: 'full' }, + { label: 'Groß (75%)', value: 'large' }, + { label: 'Mittel (50%)', value: 'medium' }, + { label: 'Klein (33%)', value: 'small' }, + ], + admin: { + description: 'Breite des Video-Containers', + }, + }, + { + name: 'alignment', + type: 'select', + defaultValue: 'center', + label: 'Ausrichtung', + options: [ + { label: 'Links', value: 'left' }, + { label: 'Zentriert', value: 'center' }, + { label: 'Rechts', value: 'right' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.size !== 'full', + }, + }, + + // === WIEDERGABE-OPTIONEN === + { + name: 'playback', + type: 'group', + label: 'Wiedergabe', + fields: [ + { + name: 'autoplay', + type: 'checkbox', + defaultValue: false, + label: 'Autoplay', + admin: { + description: 'Video automatisch starten (erfordert meist Mute)', + }, + }, + { + name: 'muted', + type: 'checkbox', + defaultValue: false, + label: 'Stummgeschaltet', + admin: { + description: 'Video stumm abspielen', + }, + }, + { + name: 'loop', + type: 'checkbox', + defaultValue: false, + label: 'Wiederholen', + admin: { + description: 'Video in Endlosschleife abspielen', + }, + }, + { + name: 'controls', + type: 'checkbox', + defaultValue: true, + label: 'Steuerung anzeigen', + admin: { + description: 'Video-Controls anzeigen', + }, + }, + { + name: 'playsinline', + type: 'checkbox', + defaultValue: true, + label: 'Inline abspielen', + admin: { + description: 'Auf Mobile inline statt Vollbild abspielen', + }, + }, + { + name: 'startTime', + type: 'number', + min: 0, + label: 'Startzeit (Sekunden)', + admin: { + description: 'Video ab dieser Sekunde starten', + }, + }, + ], + }, + + // === EMBED-OPTIONEN (nur für YouTube/Vimeo) === + { + name: 'embedOptions', + type: 'group', + label: 'Embed-Optionen', + admin: { + condition: (_, siblingData) => siblingData?.sourceType === 'embed', + }, + fields: [ + { + name: 'showRelated', + type: 'checkbox', + defaultValue: false, + label: 'Ähnliche Videos anzeigen', + admin: { + description: 'Am Ende ähnliche Videos von YouTube/Vimeo anzeigen', + }, + }, + { + name: 'privacyMode', + type: 'checkbox', + defaultValue: true, + label: 'Datenschutz-Modus', + admin: { + description: 'YouTube-nocookie.com verwenden (DSGVO-konformer)', + }, + }, + ], + }, + + // === STYLING === + { + name: 'style', + type: 'group', + label: 'Styling', + fields: [ + { + name: 'rounded', + type: 'select', + defaultValue: 'none', + label: 'Ecken abrunden', + options: [ + { label: 'Keine', value: 'none' }, + { label: 'Leicht (sm)', value: 'sm' }, + { label: 'Mittel (md)', value: 'md' }, + { label: 'Stark (lg)', value: 'lg' }, + { label: 'Extra (xl)', value: 'xl' }, + ], + }, + { + name: 'shadow', + type: 'select', + defaultValue: 'none', + label: 'Schatten', + options: [ + { label: 'Kein', value: 'none' }, + { label: 'Leicht', value: 'sm' }, + { label: 'Mittel', value: 'md' }, + { label: 'Stark', value: 'lg' }, + { label: 'Extra', value: 'xl' }, + ], + }, + { + name: 'border', + type: 'checkbox', + defaultValue: false, + label: 'Rahmen anzeigen', + }, ], }, ], diff --git a/src/collections/Posts.ts b/src/collections/Posts.ts index 6ae66f8..3d2c5a8 100644 --- a/src/collections/Posts.ts +++ b/src/collections/Posts.ts @@ -1,5 +1,6 @@ import type { CollectionConfig } from 'payload' import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess' +import { processFeaturedVideo } from '../hooks/processFeaturedVideo' /** * Berechnet die geschätzte Lesezeit basierend auf Wortanzahl @@ -105,6 +106,143 @@ export const Posts: CollectionConfig = { relationTo: 'media', label: 'Beitragsbild', }, + // === FEATURED VIDEO === + { + name: 'featuredVideo', + type: 'group', + label: 'Featured Video', + admin: { + description: 'Optional: Video als Hero-Element für diesen Beitrag', + }, + fields: [ + { + name: 'enabled', + type: 'checkbox', + defaultValue: false, + label: 'Featured Video aktivieren', + admin: { + description: 'Video als primäres Medienelement verwenden', + }, + }, + { + name: 'replaceImage', + type: 'checkbox', + defaultValue: false, + label: 'Beitragsbild ersetzen', + admin: { + description: 'Video statt Beitragsbild im Hero-Bereich anzeigen', + condition: (_, siblingData) => siblingData?.enabled === true, + }, + }, + { + name: 'source', + type: 'select', + defaultValue: 'library', + label: 'Video-Quelle', + options: [ + { label: 'Aus Video-Bibliothek', value: 'library' }, + { label: 'YouTube/Vimeo URL', value: 'embed' }, + { label: 'Video hochladen', value: 'upload' }, + ], + admin: { + condition: (_, siblingData) => siblingData?.enabled === true, + }, + }, + { + name: 'video', + type: 'relationship', + relationTo: 'videos', + label: 'Video auswählen', + admin: { + description: 'Video aus der Video-Bibliothek auswählen', + condition: (_, siblingData) => + siblingData?.enabled === true && siblingData?.source === 'library', + }, + }, + { + name: 'embedUrl', + type: 'text', + label: 'Video-URL', + admin: { + description: 'YouTube oder Vimeo URL', + condition: (_, siblingData) => + siblingData?.enabled === true && siblingData?.source === 'embed', + }, + }, + { + name: 'uploadedVideo', + type: 'upload', + relationTo: 'media', + label: 'Video-Datei', + admin: { + description: 'MP4, WebM oder andere Video-Dateien', + condition: (_, siblingData) => + siblingData?.enabled === true && siblingData?.source === 'upload', + }, + }, + { + name: 'autoplay', + type: 'checkbox', + defaultValue: false, + label: 'Autoplay', + admin: { + description: 'Video automatisch starten (erfordert Mute)', + condition: (_, siblingData) => siblingData?.enabled === true, + }, + }, + { + name: 'muted', + type: 'checkbox', + defaultValue: true, + label: 'Stummgeschaltet', + admin: { + description: 'Video stumm abspielen (empfohlen für Autoplay)', + condition: (_, siblingData) => siblingData?.enabled === true, + }, + }, + // Processed fields (populated by hook) + { + name: 'processedEmbedUrl', + type: 'text', + admin: { + readOnly: true, + description: 'Automatisch generierte Embed-URL mit Privacy-Mode', + condition: (_, siblingData) => + siblingData?.enabled === true && siblingData?.source === 'embed', + }, + }, + { + name: 'extractedVideoId', + type: 'text', + admin: { + readOnly: true, + description: 'Extrahierte Video-ID (z.B. YouTube Video-ID)', + condition: (_, siblingData) => + siblingData?.enabled === true && siblingData?.source === 'embed', + }, + }, + { + name: 'platform', + type: 'text', + admin: { + readOnly: true, + description: 'Erkannte Plattform (youtube, vimeo, etc.)', + condition: (_, siblingData) => + siblingData?.enabled === true && siblingData?.source === 'embed', + }, + }, + { + name: 'thumbnailUrl', + type: 'text', + admin: { + readOnly: true, + description: 'Auto-generierte Thumbnail-URL', + condition: (_, siblingData) => + siblingData?.enabled === true && siblingData?.source === 'embed', + }, + }, + ], + }, { name: 'content', type: 'richText', @@ -219,6 +357,7 @@ export const Posts: CollectionConfig = { ], hooks: { beforeChange: [ + processFeaturedVideo, ({ data }) => { // Automatische Lesezeit-Berechnung if (data?.content) { diff --git a/src/collections/VideoCategories.ts b/src/collections/VideoCategories.ts new file mode 100644 index 0000000..cd89b9e --- /dev/null +++ b/src/collections/VideoCategories.ts @@ -0,0 +1,92 @@ +import type { CollectionConfig } from 'payload' +import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess' +import { createSlugValidationHook } from '../lib/validation' + +export const VideoCategories: CollectionConfig = { + slug: 'video-categories', + admin: { + useAsTitle: 'name', + group: 'Medien', + description: 'Kategorien für Video-Bibliothek (z.B. Tutorials, Produktvideos, Testimonials)', + defaultColumns: ['name', 'slug', 'order', 'isActive'], + }, + access: { + read: tenantScopedPublicRead, + create: authenticatedOnly, + update: authenticatedOnly, + delete: authenticatedOnly, + }, + fields: [ + { + name: 'name', + type: 'text', + required: true, + localized: true, + label: 'Kategoriename', + admin: { + description: 'z.B. "Tutorials", "Produktvideos", "Webinare"', + }, + }, + { + name: 'slug', + type: 'text', + required: true, + unique: false, // Uniqueness per tenant/locale + label: 'URL-Slug', + admin: { + description: 'URL-freundlicher Name (z.B. "tutorials", "produktvideos")', + }, + }, + { + name: 'description', + type: 'textarea', + localized: true, + label: 'Beschreibung', + admin: { + description: 'Kurzbeschreibung der Kategorie für SEO und Übersichten', + }, + }, + { + name: 'icon', + type: 'text', + label: 'Icon', + admin: { + description: 'Icon-Name (z.B. Lucide Icon wie "play-circle", "video", "film")', + }, + }, + { + name: 'coverImage', + type: 'upload', + relationTo: 'media', + label: 'Cover-Bild', + admin: { + description: 'Repräsentatives Bild für die Kategorieübersicht', + }, + }, + { + name: 'order', + type: 'number', + defaultValue: 0, + label: 'Reihenfolge', + admin: { + position: 'sidebar', + description: 'Niedrigere Zahlen erscheinen zuerst', + }, + }, + { + name: 'isActive', + type: 'checkbox', + defaultValue: true, + label: 'Aktiv', + admin: { + position: 'sidebar', + description: 'Inaktive Kategorien werden nicht angezeigt', + }, + }, + ], + hooks: { + beforeValidate: [ + createSlugValidationHook({ collection: 'video-categories' }), + ], + }, +} diff --git a/src/collections/Videos.ts b/src/collections/Videos.ts new file mode 100644 index 0000000..844c2a3 --- /dev/null +++ b/src/collections/Videos.ts @@ -0,0 +1,413 @@ +import type { CollectionConfig } from 'payload' +import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess' +import { parseVideoUrl, parseDuration, formatDuration } from '../lib/video' +import { createSlugValidationHook } from '../lib/validation' + +/** + * Videos Collection + * + * Zentrale Video-Bibliothek mit Unterstützung für: + * - Direkte Video-Uploads + * - YouTube Embeds + * - Vimeo Embeds + * - Externe Video-URLs + */ +export const Videos: CollectionConfig = { + slug: 'videos', + admin: { + useAsTitle: 'title', + group: 'Medien', + description: 'Video-Bibliothek für YouTube/Vimeo Embeds und hochgeladene Videos', + defaultColumns: ['title', 'source', 'category', 'status', 'publishedAt'], + }, + access: { + read: tenantScopedPublicRead, + create: authenticatedOnly, + update: authenticatedOnly, + delete: authenticatedOnly, + }, + fields: [ + // === HAUPTINFOS === + { + name: 'title', + type: 'text', + required: true, + localized: true, + label: 'Titel', + admin: { + description: 'Titel des Videos', + }, + }, + { + name: 'slug', + type: 'text', + required: true, + unique: false, // Uniqueness per tenant + label: 'URL-Slug', + admin: { + description: 'URL-freundlicher Name (z.B. "produkt-tutorial")', + }, + }, + { + name: 'description', + type: 'richText', + localized: true, + label: 'Beschreibung', + admin: { + description: 'Ausführliche Beschreibung des Videos', + }, + }, + { + name: 'excerpt', + type: 'textarea', + maxLength: 300, + localized: true, + label: 'Kurzfassung', + admin: { + description: 'Kurzbeschreibung für Übersichten (max. 300 Zeichen)', + }, + }, + + // === VIDEO-QUELLE === + { + name: 'source', + type: 'select', + required: true, + defaultValue: 'youtube', + label: 'Video-Quelle', + options: [ + { label: 'YouTube', value: 'youtube' }, + { label: 'Vimeo', value: 'vimeo' }, + { label: 'Video-Upload', value: 'upload' }, + { label: 'Externe URL', value: 'external' }, + ], + admin: { + description: 'Woher stammt das Video?', + }, + }, + { + name: 'videoFile', + type: 'upload', + relationTo: 'media', + label: 'Video-Datei', + admin: { + description: 'MP4, WebM oder andere Video-Dateien', + condition: (_, siblingData) => siblingData?.source === 'upload', + }, + }, + { + name: 'embedUrl', + type: 'text', + label: 'Video-URL', + admin: { + description: 'YouTube/Vimeo URL oder direkte Video-URL', + condition: (_, siblingData) => + siblingData?.source === 'youtube' || + siblingData?.source === 'vimeo' || + siblingData?.source === 'external', + }, + }, + { + name: 'videoId', + type: 'text', + label: 'Video-ID', + admin: { + readOnly: true, + description: 'Wird automatisch aus der URL extrahiert', + condition: (_, siblingData) => + siblingData?.source === 'youtube' || siblingData?.source === 'vimeo', + }, + }, + + // === MEDIEN === + { + name: 'thumbnail', + type: 'upload', + relationTo: 'media', + label: 'Vorschaubild', + admin: { + description: 'Eigenes Thumbnail (bei YouTube wird automatisch eins verwendet falls leer)', + }, + }, + { + name: 'duration', + type: 'text', + label: 'Dauer', + admin: { + description: 'Video-Dauer (z.B. "2:30" oder "1:02:30")', + }, + }, + { + name: 'durationSeconds', + type: 'number', + label: 'Dauer (Sekunden)', + admin: { + readOnly: true, + position: 'sidebar', + description: 'Automatisch berechnet', + }, + }, + + // === KATEGORISIERUNG === + { + name: 'category', + type: 'relationship', + relationTo: 'video-categories', + label: 'Kategorie', + admin: { + description: 'Primäre Video-Kategorie', + }, + }, + { + name: 'tags', + type: 'relationship', + relationTo: 'tags', + hasMany: true, + label: 'Tags', + admin: { + description: 'Schlagwörter für bessere Auffindbarkeit', + }, + }, + { + name: 'videoType', + type: 'select', + label: 'Video-Typ', + defaultValue: 'other', + options: [ + { label: 'Tutorial', value: 'tutorial' }, + { label: 'Produktvideo', value: 'product' }, + { label: 'Testimonial', value: 'testimonial' }, + { label: 'Erklärvideo', value: 'explainer' }, + { label: 'Webinar', value: 'webinar' }, + { label: 'Interview', value: 'interview' }, + { label: 'Event', value: 'event' }, + { label: 'Trailer', value: 'trailer' }, + { label: 'Sonstiges', value: 'other' }, + ], + admin: { + position: 'sidebar', + description: 'Art des Videos', + }, + }, + + // === WIEDERGABE-OPTIONEN === + { + name: 'playback', + type: 'group', + label: 'Wiedergabe-Optionen', + fields: [ + { + name: 'autoplay', + type: 'checkbox', + defaultValue: false, + label: 'Autoplay', + admin: { + description: 'Video automatisch starten (Browser blockieren oft ohne Mute)', + }, + }, + { + name: 'muted', + type: 'checkbox', + defaultValue: false, + label: 'Stummgeschaltet', + admin: { + description: 'Video stumm abspielen (erforderlich für Autoplay in Browsern)', + }, + }, + { + name: 'loop', + type: 'checkbox', + defaultValue: false, + label: 'Wiederholen', + admin: { + description: 'Video in Endlosschleife abspielen', + }, + }, + { + name: 'controls', + type: 'checkbox', + defaultValue: true, + label: 'Steuerung anzeigen', + admin: { + description: 'Video-Controls anzeigen', + }, + }, + { + name: 'startTime', + type: 'number', + min: 0, + label: 'Startzeit (Sekunden)', + admin: { + description: 'Video ab dieser Sekunde starten', + }, + }, + ], + }, + + // === DARSTELLUNG === + { + name: 'aspectRatio', + type: 'select', + defaultValue: '16:9', + label: 'Seitenverhältnis', + options: [ + { label: '16:9 (Standard)', value: '16:9' }, + { label: '4:3', value: '4:3' }, + { label: '1:1 (Quadrat)', value: '1:1' }, + { label: '9:16 (Vertikal)', value: '9:16' }, + { label: '21:9 (Ultrawide)', value: '21:9' }, + ], + admin: { + position: 'sidebar', + description: 'Anzeigeverhältnis des Videos', + }, + }, + + // === STATUS & PUBLISHING === + { + name: 'status', + type: 'select', + defaultValue: 'draft', + label: 'Status', + options: [ + { label: 'Entwurf', value: 'draft' }, + { label: 'Veröffentlicht', value: 'published' }, + { label: 'Archiviert', value: 'archived' }, + ], + admin: { + position: 'sidebar', + }, + }, + { + name: 'isFeatured', + type: 'checkbox', + defaultValue: false, + label: 'Hervorgehoben', + admin: { + position: 'sidebar', + description: 'Als Featured Video markieren', + }, + }, + { + name: 'publishedAt', + type: 'date', + label: 'Veröffentlichungsdatum', + admin: { + position: 'sidebar', + date: { + pickerAppearance: 'dayAndTime', + }, + }, + }, + + // === VERKNÜPFUNGEN === + { + name: 'relatedVideos', + type: 'relationship', + relationTo: 'videos', + hasMany: true, + label: 'Verwandte Videos', + admin: { + description: 'Weitere Videos zu diesem Thema', + }, + }, + { + name: 'relatedPosts', + type: 'relationship', + relationTo: 'posts', + hasMany: true, + label: 'Verwandte Beiträge', + admin: { + description: 'Blog-Beiträge zu diesem Video', + }, + }, + + // === TRANSCRIPT === + { + name: 'transcript', + type: 'richText', + localized: true, + label: 'Transkript', + admin: { + description: 'Vollständiges Transkript für SEO und Barrierefreiheit', + }, + }, + + // === SEO === + { + name: 'seo', + type: 'group', + label: 'SEO', + fields: [ + { + name: 'metaTitle', + type: 'text', + localized: true, + label: 'Meta-Titel', + admin: { + description: 'SEO-Titel (falls abweichend vom Video-Titel)', + }, + }, + { + name: 'metaDescription', + type: 'textarea', + maxLength: 160, + label: 'Meta-Beschreibung', + admin: { + description: 'SEO-Beschreibung (max. 160 Zeichen)', + }, + }, + { + name: 'ogImage', + type: 'upload', + relationTo: 'media', + label: 'Social Media Bild', + admin: { + description: 'Bild für Social Media Shares (Fallback: Thumbnail)', + }, + }, + ], + }, + ], + hooks: { + beforeValidate: [ + createSlugValidationHook({ collection: 'videos' }), + ], + beforeChange: [ + ({ data }) => { + if (!data) return data + + // Auto-Slug generieren falls leer + if (!data.slug && data.title) { + data.slug = data.title + .toLowerCase() + .replace(/[äöüß]/g, (char: string) => { + const map: Record = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' } + return map[char] || char + }) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + } + + // Video-ID aus URL extrahieren + if (data.embedUrl && (data.source === 'youtube' || data.source === 'vimeo')) { + const videoInfo = parseVideoUrl(data.embedUrl) + if (videoInfo?.videoId) { + data.videoId = videoInfo.videoId + } + } + + // Dauer zu Sekunden konvertieren + if (data.duration) { + data.durationSeconds = parseDuration(data.duration) + // Dauer normalisieren + if (data.durationSeconds > 0) { + data.duration = formatDuration(data.durationSeconds) + } + } + + return data + }, + ], + }, +} diff --git a/src/hooks/processFeaturedVideo.ts b/src/hooks/processFeaturedVideo.ts new file mode 100644 index 0000000..a4f50ef --- /dev/null +++ b/src/hooks/processFeaturedVideo.ts @@ -0,0 +1,88 @@ +/** + * Featured Video Processing Hook + * + * Verarbeitet featuredVideo.embedUrl in Posts: + * - Extrahiert Video-ID aus URL + * - Generiert normalisierte Embed-URL mit Privacy-Mode + */ + +import type { CollectionBeforeChangeHook } from 'payload' +import { parseVideoUrl, generateEmbedUrl } from '../lib/video' + +interface FeaturedVideoData { + enabled?: boolean + source?: 'library' | 'embed' | 'upload' + embedUrl?: string + video?: number | string + uploadedVideo?: number | string + autoplay?: boolean + muted?: boolean + replaceImage?: boolean + // Processed fields (added by this hook) + processedEmbedUrl?: string + extractedVideoId?: string + platform?: string + thumbnailUrl?: string +} + +interface PostData { + featuredVideo?: FeaturedVideoData + [key: string]: unknown +} + +/** + * Hook zum Verarbeiten von featuredVideo Embed-URLs + * + * - Extrahiert Video-ID und Plattform aus der URL + * - Generiert normalisierte Embed-URL mit Privacy-Mode (youtube-nocookie) + * - Speichert Thumbnail-URL für Fallback + */ +export const processFeaturedVideo: CollectionBeforeChangeHook = async ({ + data, + operation, +}) => { + // Nur wenn featuredVideo existiert und aktiviert ist + if (!data?.featuredVideo?.enabled) { + return data + } + + const featuredVideo = data.featuredVideo + + // Nur für embed source verarbeiten + if (featuredVideo.source !== 'embed' || !featuredVideo.embedUrl) { + return data + } + + const embedUrl = featuredVideo.embedUrl.trim() + + // URL parsen + const videoInfo = parseVideoUrl(embedUrl) + + if (!videoInfo || videoInfo.platform === 'unknown') { + // URL konnte nicht geparst werden - unverändert lassen + console.warn(`[processFeaturedVideo] Could not parse video URL: ${embedUrl}`) + return data + } + + // Video-Metadaten speichern + featuredVideo.extractedVideoId = videoInfo.videoId || undefined + featuredVideo.platform = videoInfo.platform + featuredVideo.thumbnailUrl = videoInfo.thumbnailUrl || undefined + + // Embed-URL mit Privacy-Mode und Playback-Optionen generieren + const processedUrl = generateEmbedUrl(videoInfo, { + autoplay: featuredVideo.autoplay ?? false, + muted: featuredVideo.muted ?? true, + privacyMode: true, // Immer Privacy-Mode für DSGVO + showRelated: false, // Keine verwandten Videos + }) + + if (processedUrl) { + featuredVideo.processedEmbedUrl = processedUrl + } + + return { + ...data, + featuredVideo, + } +} diff --git a/src/lib/validation/index.ts b/src/lib/validation/index.ts new file mode 100644 index 0000000..36dfc6d --- /dev/null +++ b/src/lib/validation/index.ts @@ -0,0 +1,12 @@ +/** + * Validation Module + * + * Exportiert alle Validierungs-Funktionen. + */ + +export { + validateUniqueSlug, + createSlugValidationHook, + generateUniqueSlug, + type SlugValidationOptions, +} from './slug-validation' diff --git a/src/lib/validation/slug-validation.ts b/src/lib/validation/slug-validation.ts new file mode 100644 index 0000000..3002737 --- /dev/null +++ b/src/lib/validation/slug-validation.ts @@ -0,0 +1,156 @@ +/** + * Slug Validation Utilities + * + * Stellt sicher, dass Slugs innerhalb eines Tenants eindeutig sind. + */ + +import type { Payload } from 'payload' +import type { Config } from '@/payload-types' + +type CollectionSlug = keyof Config['collections'] + +export interface SlugValidationOptions { + /** Collection slug */ + collection: CollectionSlug + /** Field name for slug (default: 'slug') */ + slugField?: string + /** Field name for tenant (default: 'tenant') */ + tenantField?: string + /** Whether to check per locale (default: false) */ + perLocale?: boolean +} + +/** + * Validates that a slug is unique within a tenant + * + * @throws Error if slug already exists for this tenant + */ +export async function validateUniqueSlug( + payload: Payload, + data: Record, + options: SlugValidationOptions & { + existingId?: number | string + locale?: string + } +): Promise { + const { + collection, + slugField = 'slug', + tenantField = 'tenant', + perLocale = false, + existingId, + locale, + } = options + + const slug = data[slugField] + const tenantId = data[tenantField] + + // Skip if no slug provided + if (!slug || typeof slug !== 'string') { + return + } + + // Build where clause + const where: Record = { + [slugField]: { equals: slug }, + } + + // Add tenant filter if tenant is set + if (tenantId) { + where[tenantField] = { equals: tenantId } + } + + // Exclude current document when updating + if (existingId) { + where.id = { not_equals: existingId } + } + + // Check for existing documents with same slug + const existing = await payload.find({ + collection, + where, + limit: 1, + depth: 0, + locale: perLocale ? locale : undefined, + }) + + if (existing.totalDocs > 0) { + const tenantInfo = tenantId ? ` für diesen Tenant` : '' + throw new Error(`Der Slug "${slug}" existiert bereits${tenantInfo}. Bitte wählen Sie einen anderen.`) + } +} + +/** + * Creates a beforeValidate hook for slug uniqueness + */ +export function createSlugValidationHook(options: SlugValidationOptions) { + return async ({ + data, + req, + operation, + originalDoc, + }: { + data?: Record + req: { payload: Payload; locale?: string } + operation: 'create' | 'update' + originalDoc?: { id?: number | string } + }) => { + if (!data) return data + + await validateUniqueSlug(req.payload, data, { + ...options, + existingId: operation === 'update' ? originalDoc?.id : undefined, + locale: req.locale, + }) + + return data + } +} + +/** + * Generates a unique slug by appending a number if necessary + */ +export async function generateUniqueSlug( + payload: Payload, + baseSlug: string, + options: SlugValidationOptions & { + existingId?: number | string + tenantId?: number | string + } +): Promise { + const { collection, slugField = 'slug', tenantField = 'tenant', existingId, tenantId } = options + + let slug = baseSlug + let counter = 1 + let isUnique = false + + while (!isUnique && counter < 100) { + const where: Record = { + [slugField]: { equals: slug }, + } + + if (tenantId) { + where[tenantField] = { equals: tenantId } + } + + if (existingId) { + where.id = { not_equals: existingId } + } + + const existing = await payload.find({ + collection, + where, + limit: 1, + depth: 0, + }) + + if (existing.totalDocs === 0) { + isUnique = true + } else { + slug = `${baseSlug}-${counter}` + counter++ + } + } + + return slug +} diff --git a/src/lib/video/index.ts b/src/lib/video/index.ts new file mode 100644 index 0000000..e6b225b --- /dev/null +++ b/src/lib/video/index.ts @@ -0,0 +1,21 @@ +/** + * Video Module + * + * Exportiert alle Video-bezogenen Funktionen und Typen. + */ + +export { + parseVideoUrl, + generateEmbedUrl, + formatDuration, + parseDuration, + getAspectRatioClass, + extractVideoId, + isValidVideoUrl, + getVideoPlatform, + getVideoThumbnail, + validateVideoUrl, + type VideoPlatform, + type VideoInfo, + type EmbedOptions, +} from './video-utils' diff --git a/src/lib/video/video-utils.ts b/src/lib/video/video-utils.ts new file mode 100644 index 0000000..e196afd --- /dev/null +++ b/src/lib/video/video-utils.ts @@ -0,0 +1,352 @@ +/** + * Video Utility Functions + * + * Hilfsfunktionen für Video-URL-Parsing, Embed-Generierung und Formatierung. + */ + +export type VideoPlatform = 'youtube' | 'vimeo' | 'external' | 'unknown' + +export interface VideoInfo { + platform: VideoPlatform + videoId: string | null + originalUrl: string + embedUrl: string | null + thumbnailUrl: string | null +} + +export interface EmbedOptions { + autoplay?: boolean + muted?: boolean + loop?: boolean + controls?: boolean + startTime?: number + privacyMode?: boolean + showRelated?: boolean +} + +/** + * Parst eine Video-URL und extrahiert Plattform, Video-ID und Embed-URL + */ +export function parseVideoUrl(url: string): VideoInfo | null { + if (!url || typeof url !== 'string') { + return null + } + + const trimmedUrl = url.trim() + + // YouTube URL patterns + const youtubePatterns = [ + // Standard watch URL: youtube.com/watch?v=VIDEO_ID + /(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})(?:&.*)?/, + // Short URL: youtu.be/VIDEO_ID + /(?:https?:\/\/)?(?:www\.)?youtu\.be\/([a-zA-Z0-9_-]{11})(?:\?.*)?/, + // Embed URL: youtube.com/embed/VIDEO_ID + /(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?.*)?/, + // YouTube-nocookie (privacy mode) + /(?:https?:\/\/)?(?:www\.)?youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?.*)?/, + // Shorts URL: youtube.com/shorts/VIDEO_ID + /(?:https?:\/\/)?(?:www\.)?youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})(?:\?.*)?/, + ] + + for (const pattern of youtubePatterns) { + const match = trimmedUrl.match(pattern) + if (match && match[1]) { + const videoId = match[1] + return { + platform: 'youtube', + videoId, + originalUrl: trimmedUrl, + embedUrl: `https://www.youtube.com/embed/${videoId}`, + thumbnailUrl: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`, + } + } + } + + // Vimeo URL patterns + const vimeoPatterns = [ + // Standard URL: vimeo.com/VIDEO_ID + /(?:https?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)(?:\?.*)?/, + // Player URL: player.vimeo.com/video/VIDEO_ID + /(?:https?:\/\/)?player\.vimeo\.com\/video\/(\d+)(?:\?.*)?/, + // Channel URL: vimeo.com/channels/CHANNEL/VIDEO_ID + /(?:https?:\/\/)?(?:www\.)?vimeo\.com\/channels\/[^/]+\/(\d+)(?:\?.*)?/, + // Groups URL: vimeo.com/groups/GROUP/videos/VIDEO_ID + /(?:https?:\/\/)?(?:www\.)?vimeo\.com\/groups\/[^/]+\/videos\/(\d+)(?:\?.*)?/, + ] + + for (const pattern of vimeoPatterns) { + const match = trimmedUrl.match(pattern) + if (match && match[1]) { + const videoId = match[1] + return { + platform: 'vimeo', + videoId, + originalUrl: trimmedUrl, + embedUrl: `https://player.vimeo.com/video/${videoId}`, + thumbnailUrl: null, // Vimeo requires API call for thumbnail + } + } + } + + // Check if it's a direct video file URL + const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv'] + const isVideoFile = videoExtensions.some((ext) => + trimmedUrl.toLowerCase().includes(ext) + ) + + if (isVideoFile) { + return { + platform: 'external', + videoId: null, + originalUrl: trimmedUrl, + embedUrl: trimmedUrl, + thumbnailUrl: null, + } + } + + // Unknown URL format + return { + platform: 'unknown', + videoId: null, + originalUrl: trimmedUrl, + embedUrl: null, + thumbnailUrl: null, + } +} + +/** + * Generiert eine Embed-URL mit den angegebenen Optionen + */ +export function generateEmbedUrl( + videoInfo: VideoInfo, + options: EmbedOptions = {} +): string | null { + if (!videoInfo || !videoInfo.embedUrl) { + return null + } + + const { + autoplay = false, + muted = false, + loop = false, + controls = true, + startTime = 0, + privacyMode = false, + showRelated = false, + } = options + + const params = new URLSearchParams() + + if (videoInfo.platform === 'youtube') { + // YouTube-spezifische Parameter + let baseUrl = videoInfo.embedUrl + + // Privacy Mode: youtube-nocookie.com verwenden + if (privacyMode) { + baseUrl = baseUrl.replace('youtube.com', 'youtube-nocookie.com') + } + + if (autoplay) params.set('autoplay', '1') + if (muted) params.set('mute', '1') + if (loop && videoInfo.videoId) { + params.set('loop', '1') + params.set('playlist', videoInfo.videoId) // Loop benötigt playlist Parameter + } + if (!controls) params.set('controls', '0') + if (startTime > 0) params.set('start', String(Math.floor(startTime))) + if (!showRelated) params.set('rel', '0') + + // Modestbranding und iv_load_policy für cleanes Embedding + params.set('modestbranding', '1') + params.set('iv_load_policy', '3') // Annotationen ausblenden + + const paramString = params.toString() + return paramString ? `${baseUrl}?${paramString}` : baseUrl + } + + if (videoInfo.platform === 'vimeo') { + // Vimeo-spezifische Parameter + if (autoplay) params.set('autoplay', '1') + if (muted) params.set('muted', '1') + if (loop) params.set('loop', '1') + if (!controls) params.set('controls', '0') + + // Vimeo unterstützt startTime als #t=XXs + let url = videoInfo.embedUrl + const paramString = params.toString() + if (paramString) { + url = `${url}?${paramString}` + } + if (startTime > 0) { + url = `${url}#t=${Math.floor(startTime)}s` + } + + return url + } + + // Für externe URLs keine Parameter hinzufügen + return videoInfo.embedUrl +} + +/** + * Formatiert Sekunden als Dauer-String (z.B. "2:30" oder "1:02:30") + */ +export function formatDuration(seconds: number): string { + if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) { + return '0:00' + } + + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = Math.floor(seconds % 60) + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}` + } + + return `${minutes}:${String(secs).padStart(2, '0')}` +} + +/** + * Parst einen Dauer-String zu Sekunden + * Unterstützt: "2:30", "1:02:30", "90", "1h 30m", "90s" + */ +export function parseDuration(duration: string): number { + if (!duration || typeof duration !== 'string') { + return 0 + } + + const trimmed = duration.trim() + + // Format: "HH:MM:SS" oder "MM:SS" + if (trimmed.includes(':')) { + const parts = trimmed.split(':').map((p) => parseInt(p, 10)) + + if (parts.length === 3) { + // HH:MM:SS + const [hours, minutes, seconds] = parts + return (hours || 0) * 3600 + (minutes || 0) * 60 + (seconds || 0) + } + + if (parts.length === 2) { + // MM:SS + const [minutes, seconds] = parts + return (minutes || 0) * 60 + (seconds || 0) + } + } + + // Format: "1h 30m 45s" oder Kombinationen + const hourMatch = trimmed.match(/(\d+)\s*h/i) + const minuteMatch = trimmed.match(/(\d+)\s*m/i) + const secondMatch = trimmed.match(/(\d+)\s*s/i) + + if (hourMatch || minuteMatch || secondMatch) { + const hours = hourMatch ? parseInt(hourMatch[1], 10) : 0 + const minutes = minuteMatch ? parseInt(minuteMatch[1], 10) : 0 + const seconds = secondMatch ? parseInt(secondMatch[1], 10) : 0 + return hours * 3600 + minutes * 60 + seconds + } + + // Nur Sekunden als Zahl + const numericValue = parseInt(trimmed, 10) + return isNaN(numericValue) ? 0 : numericValue +} + +/** + * Gibt die passende Tailwind-CSS-Klasse für ein Aspect-Ratio zurück + */ +export function getAspectRatioClass(ratio: string): string { + const ratioMap: Record = { + '16:9': 'aspect-video', // aspect-[16/9] + '4:3': 'aspect-[4/3]', + '1:1': 'aspect-square', // aspect-[1/1] + '9:16': 'aspect-[9/16]', + '21:9': 'aspect-[21/9]', + '3:2': 'aspect-[3/2]', + '2:3': 'aspect-[2/3]', + } + + return ratioMap[ratio] || 'aspect-video' +} + +/** + * Extrahiert die Video-ID aus einer URL + */ +export function extractVideoId(url: string): string | null { + const info = parseVideoUrl(url) + return info?.videoId || null +} + +/** + * Prüft ob eine URL eine gültige Video-URL ist + */ +export function isValidVideoUrl(url: string): boolean { + const info = parseVideoUrl(url) + return info !== null && info.platform !== 'unknown' +} + +/** + * Gibt die Plattform einer Video-URL zurück + */ +export function getVideoPlatform(url: string): VideoPlatform { + const info = parseVideoUrl(url) + return info?.platform || 'unknown' +} + +/** + * Generiert eine Thumbnail-URL für ein Video + * Für YouTube direkt, für Vimeo wird null zurückgegeben (API erforderlich) + */ +export function getVideoThumbnail( + url: string, + quality: 'default' | 'medium' | 'high' | 'max' = 'high' +): string | null { + const info = parseVideoUrl(url) + + if (!info || !info.videoId) { + return null + } + + if (info.platform === 'youtube') { + const qualityMap: Record = { + default: 'default.jpg', + medium: 'mqdefault.jpg', + high: 'hqdefault.jpg', + max: 'maxresdefault.jpg', + } + return `https://img.youtube.com/vi/${info.videoId}/${qualityMap[quality]}` + } + + // Vimeo Thumbnails benötigen API-Aufruf + return null +} + +/** + * Validiert eine Video-URL und gibt Fehlermeldungen zurück + */ +export function validateVideoUrl(url: string): { valid: boolean; error?: string } { + if (!url || typeof url !== 'string') { + return { valid: false, error: 'URL ist erforderlich' } + } + + const trimmed = url.trim() + + if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) { + return { valid: false, error: 'URL muss mit http:// oder https:// beginnen' } + } + + const info = parseVideoUrl(trimmed) + + if (!info) { + return { valid: false, error: 'Ungültige URL' } + } + + if (info.platform === 'unknown') { + return { + valid: false, + error: 'Unbekanntes Video-Format. Unterstützt: YouTube, Vimeo, oder direkte Video-URLs', + } + } + + return { valid: true } +} diff --git a/src/migrations/20251216_073000_add_video_collections.ts b/src/migrations/20251216_073000_add_video_collections.ts new file mode 100644 index 0000000..f19eac1 --- /dev/null +++ b/src/migrations/20251216_073000_add_video_collections.ts @@ -0,0 +1,470 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Migration: Add Video Collections + * + * Creates: + * - video_categories table (with locales) + * - videos table (with locales) + * - videos_tags (m:n) + * - videos_rels (for related videos/posts) + * - Extends posts table with featured_video fields + * - Extends pages_blocks_video_block with new fields + */ +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + await db.execute(sql` + + -- ENUMS for videos collection (with DO...EXCEPTION for idempotency) + DO $$ BEGIN + CREATE TYPE "public"."enum_videos_source" AS ENUM('youtube', 'vimeo', 'upload', 'external'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum_videos_video_type" AS ENUM('tutorial', 'product', 'testimonial', 'explainer', 'webinar', 'interview', 'event', 'trailer', 'other'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum_videos_aspect_ratio" AS ENUM('16:9', '4:3', '1:1', '9:16', '21:9'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum_videos_status" AS ENUM('draft', 'published', 'archived'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + -- ENUMS for posts featured_video + DO $$ BEGIN + CREATE TYPE "public"."enum_posts_featured_video_source" AS ENUM('library', 'embed', 'upload'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + -- ENUMS for video_block + DO $$ BEGIN + CREATE TYPE "public"."enum_pages_blocks_video_block_source_type" AS ENUM('embed', 'upload', 'library', 'external'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + -- Add new values to existing aspect_ratio enum if they don't exist + DO $$ BEGIN + ALTER TYPE "public"."enum_pages_blocks_video_block_aspect_ratio" ADD VALUE IF NOT EXISTS '9:16'; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TYPE "public"."enum_pages_blocks_video_block_aspect_ratio" ADD VALUE IF NOT EXISTS '21:9'; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum_pages_blocks_video_block_size" AS ENUM('full', 'large', 'medium', 'small'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum_pages_blocks_video_block_alignment" AS ENUM('left', 'center', 'right'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum_pages_blocks_video_block_style_rounded" AS ENUM('none', 'sm', 'md', 'lg', 'xl'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum_pages_blocks_video_block_style_shadow" AS ENUM('none', 'sm', 'md', 'lg', 'xl'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + -- ============================================================ + -- VIDEO CATEGORIES TABLE + -- ============================================================ + CREATE TABLE IF NOT EXISTS "video_categories" ( + "id" serial PRIMARY KEY NOT NULL, + "tenant_id" integer, + "slug" varchar NOT NULL, + "icon" varchar, + "cover_image_id" integer, + "order" numeric DEFAULT 0, + "is_active" boolean DEFAULT true, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE IF NOT EXISTS "video_categories_locales" ( + "name" varchar NOT NULL, + "description" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" integer NOT NULL + ); + + -- ============================================================ + -- VIDEOS TABLE + -- ============================================================ + CREATE TABLE IF NOT EXISTS "videos" ( + "id" serial PRIMARY KEY NOT NULL, + "tenant_id" integer, + "slug" varchar NOT NULL, + "source" "enum_videos_source" DEFAULT 'youtube' NOT NULL, + "video_file_id" integer, + "embed_url" varchar, + "video_id" varchar, + "thumbnail_id" integer, + "duration" varchar, + "duration_seconds" numeric, + "category_id" integer, + "video_type" "enum_videos_video_type" DEFAULT 'other', + "playback_autoplay" boolean DEFAULT false, + "playback_muted" boolean DEFAULT false, + "playback_loop" boolean DEFAULT false, + "playback_controls" boolean DEFAULT true, + "playback_start_time" numeric, + "aspect_ratio" "enum_videos_aspect_ratio" DEFAULT '16:9', + "status" "enum_videos_status" DEFAULT 'draft', + "is_featured" boolean DEFAULT false, + "published_at" timestamp(3) with time zone, + "seo_meta_description" varchar, + "seo_og_image_id" integer, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE IF NOT EXISTS "videos_locales" ( + "title" varchar NOT NULL, + "description" jsonb, + "excerpt" varchar, + "transcript" jsonb, + "seo_meta_title" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" integer NOT NULL + ); + + -- Videos Tags (m:n) + CREATE TABLE IF NOT EXISTS "videos_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL, + "path" varchar NOT NULL, + "tags_id" integer, + "videos_id" integer, + "posts_id" integer + ); + + -- ============================================================ + -- POSTS FEATURED VIDEO COLUMNS + -- ============================================================ + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_enabled" boolean DEFAULT false; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_replace_image" boolean DEFAULT false; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_source" "enum_posts_featured_video_source" DEFAULT 'library'; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_video_id" integer; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_embed_url" varchar; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_uploaded_video_id" integer; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_autoplay" boolean DEFAULT false; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_muted" boolean DEFAULT true; + + -- ============================================================ + -- PAGES BLOCKS VIDEO BLOCK - Extended columns + -- ============================================================ + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "source_type" "enum_pages_blocks_video_block_source_type" DEFAULT 'embed'; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "video_from_library_id" integer; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "video_file_id" integer; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "thumbnail_id" integer; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "size" "enum_pages_blocks_video_block_size" DEFAULT 'full'; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "alignment" "enum_pages_blocks_video_block_alignment" DEFAULT 'center'; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "playback_autoplay" boolean DEFAULT false; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "playback_muted" boolean DEFAULT false; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "playback_loop" boolean DEFAULT false; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "playback_controls" boolean DEFAULT true; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "playback_playsinline" boolean DEFAULT true; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "playback_start_time" numeric; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "embed_options_show_related" boolean DEFAULT false; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "embed_options_privacy_mode" boolean DEFAULT true; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "style_rounded" "enum_pages_blocks_video_block_style_rounded" DEFAULT 'none'; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "style_shadow" "enum_pages_blocks_video_block_style_shadow" DEFAULT 'none'; + ALTER TABLE "pages_blocks_video_block" ADD COLUMN IF NOT EXISTS "style_border" boolean DEFAULT false; + + -- ============================================================ + -- INDEXES + -- ============================================================ + CREATE INDEX IF NOT EXISTS "video_categories_tenant_idx" ON "video_categories" USING btree ("tenant_id"); + CREATE INDEX IF NOT EXISTS "video_categories_slug_idx" ON "video_categories" USING btree ("slug"); + CREATE INDEX IF NOT EXISTS "video_categories_created_at_idx" ON "video_categories" USING btree ("created_at"); + CREATE UNIQUE INDEX IF NOT EXISTS "video_categories_locales_locale_parent_id_unique" ON "video_categories_locales" USING btree ("_locale","_parent_id"); + + CREATE INDEX IF NOT EXISTS "videos_tenant_idx" ON "videos" USING btree ("tenant_id"); + CREATE INDEX IF NOT EXISTS "videos_slug_idx" ON "videos" USING btree ("slug"); + CREATE INDEX IF NOT EXISTS "videos_source_idx" ON "videos" USING btree ("source"); + CREATE INDEX IF NOT EXISTS "videos_category_idx" ON "videos" USING btree ("category_id"); + CREATE INDEX IF NOT EXISTS "videos_status_idx" ON "videos" USING btree ("status"); + CREATE INDEX IF NOT EXISTS "videos_is_featured_idx" ON "videos" USING btree ("is_featured"); + CREATE INDEX IF NOT EXISTS "videos_published_at_idx" ON "videos" USING btree ("published_at"); + CREATE INDEX IF NOT EXISTS "videos_created_at_idx" ON "videos" USING btree ("created_at"); + CREATE UNIQUE INDEX IF NOT EXISTS "videos_locales_locale_parent_id_unique" ON "videos_locales" USING btree ("_locale","_parent_id"); + + CREATE INDEX IF NOT EXISTS "videos_rels_order_idx" ON "videos_rels" USING btree ("order"); + CREATE INDEX IF NOT EXISTS "videos_rels_parent_idx" ON "videos_rels" USING btree ("parent_id"); + CREATE INDEX IF NOT EXISTS "videos_rels_path_idx" ON "videos_rels" USING btree ("path"); + CREATE INDEX IF NOT EXISTS "videos_rels_tags_idx" ON "videos_rels" USING btree ("tags_id"); + CREATE INDEX IF NOT EXISTS "videos_rels_videos_idx" ON "videos_rels" USING btree ("videos_id"); + CREATE INDEX IF NOT EXISTS "videos_rels_posts_idx" ON "videos_rels" USING btree ("posts_id"); + + -- ============================================================ + -- FOREIGN KEYS + -- ============================================================ + DO $$ BEGIN + ALTER TABLE "video_categories" ADD CONSTRAINT "video_categories_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "video_categories" ADD CONSTRAINT "video_categories_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "public"."media"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "video_categories_locales" ADD CONSTRAINT "video_categories_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."video_categories"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos" ADD CONSTRAINT "videos_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos" ADD CONSTRAINT "videos_video_file_id_media_id_fk" FOREIGN KEY ("video_file_id") REFERENCES "public"."media"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos" ADD CONSTRAINT "videos_thumbnail_id_media_id_fk" FOREIGN KEY ("thumbnail_id") REFERENCES "public"."media"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos" ADD CONSTRAINT "videos_category_id_video_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."video_categories"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos" ADD CONSTRAINT "videos_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos_locales" ADD CONSTRAINT "videos_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."videos"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos_rels" ADD CONSTRAINT "videos_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."videos"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos_rels" ADD CONSTRAINT "videos_rels_tags_fk" FOREIGN KEY ("tags_id") REFERENCES "public"."tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos_rels" ADD CONSTRAINT "videos_rels_videos_fk" FOREIGN KEY ("videos_id") REFERENCES "public"."videos"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "videos_rels" ADD CONSTRAINT "videos_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "public"."posts"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "posts" ADD CONSTRAINT "posts_featured_video_video_id_videos_id_fk" FOREIGN KEY ("featured_video_video_id") REFERENCES "public"."videos"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "posts" ADD CONSTRAINT "posts_featured_video_uploaded_video_id_media_id_fk" FOREIGN KEY ("featured_video_uploaded_video_id") REFERENCES "public"."media"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "pages_blocks_video_block" ADD CONSTRAINT "pages_blocks_video_block_video_from_library_id_videos_id_fk" FOREIGN KEY ("video_from_library_id") REFERENCES "public"."videos"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "pages_blocks_video_block" ADD CONSTRAINT "pages_blocks_video_block_video_file_id_media_id_fk" FOREIGN KEY ("video_file_id") REFERENCES "public"."media"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "pages_blocks_video_block" ADD CONSTRAINT "pages_blocks_video_block_thumbnail_id_media_id_fk" FOREIGN KEY ("thumbnail_id") REFERENCES "public"."media"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + -- ============================================================ + -- PAYLOAD INTERNAL TABLES - Add columns for new collections + -- ============================================================ + + -- payload_locked_documents_rels + ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "videos_id" integer; + ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "video_categories_id" integer; + CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_videos_id_idx" ON "payload_locked_documents_rels" USING btree ("videos_id"); + CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_video_categories_id_idx" ON "payload_locked_documents_rels" USING btree ("video_categories_id"); + + DO $$ BEGIN + ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_videos_fk" FOREIGN KEY ("videos_id") REFERENCES "public"."videos"("id") ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_video_categories_fk" FOREIGN KEY ("video_categories_id") REFERENCES "public"."video_categories"("id") ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + -- payload_preferences_rels + ALTER TABLE "payload_preferences_rels" ADD COLUMN IF NOT EXISTS "videos_id" integer; + ALTER TABLE "payload_preferences_rels" ADD COLUMN IF NOT EXISTS "video_categories_id" integer; + CREATE INDEX IF NOT EXISTS "payload_preferences_rels_videos_id_idx" ON "payload_preferences_rels" USING btree ("videos_id"); + CREATE INDEX IF NOT EXISTS "payload_preferences_rels_video_categories_id_idx" ON "payload_preferences_rels" USING btree ("video_categories_id"); + + DO $$ BEGIN + ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_videos_fk" FOREIGN KEY ("videos_id") REFERENCES "public"."videos"("id") ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_video_categories_fk" FOREIGN KEY ("video_categories_id") REFERENCES "public"."video_categories"("id") ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + `); +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await db.execute(sql` + + -- Drop payload internal table columns first + ALTER TABLE "payload_preferences_rels" DROP CONSTRAINT IF EXISTS "payload_preferences_rels_video_categories_fk"; + ALTER TABLE "payload_preferences_rels" DROP CONSTRAINT IF EXISTS "payload_preferences_rels_videos_fk"; + ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_video_categories_fk"; + ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_videos_fk"; + + DROP INDEX IF EXISTS "payload_preferences_rels_video_categories_id_idx"; + DROP INDEX IF EXISTS "payload_preferences_rels_videos_id_idx"; + DROP INDEX IF EXISTS "payload_locked_documents_rels_video_categories_id_idx"; + DROP INDEX IF EXISTS "payload_locked_documents_rels_videos_id_idx"; + + ALTER TABLE "payload_preferences_rels" DROP COLUMN IF EXISTS "video_categories_id"; + ALTER TABLE "payload_preferences_rels" DROP COLUMN IF EXISTS "videos_id"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "video_categories_id"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "videos_id"; + + -- Drop foreign keys + ALTER TABLE "pages_blocks_video_block" DROP CONSTRAINT IF EXISTS "pages_blocks_video_block_thumbnail_id_media_id_fk"; + ALTER TABLE "pages_blocks_video_block" DROP CONSTRAINT IF EXISTS "pages_blocks_video_block_video_file_id_media_id_fk"; + ALTER TABLE "pages_blocks_video_block" DROP CONSTRAINT IF EXISTS "pages_blocks_video_block_video_from_library_id_videos_id_fk"; + ALTER TABLE "posts" DROP CONSTRAINT IF EXISTS "posts_featured_video_uploaded_video_id_media_id_fk"; + ALTER TABLE "posts" DROP CONSTRAINT IF EXISTS "posts_featured_video_video_id_videos_id_fk"; + ALTER TABLE "videos_rels" DROP CONSTRAINT IF EXISTS "videos_rels_posts_fk"; + ALTER TABLE "videos_rels" DROP CONSTRAINT IF EXISTS "videos_rels_videos_fk"; + ALTER TABLE "videos_rels" DROP CONSTRAINT IF EXISTS "videos_rels_tags_fk"; + ALTER TABLE "videos_rels" DROP CONSTRAINT IF EXISTS "videos_rels_parent_fk"; + ALTER TABLE "videos_locales" DROP CONSTRAINT IF EXISTS "videos_locales_parent_id_fk"; + ALTER TABLE "videos" DROP CONSTRAINT IF EXISTS "videos_seo_og_image_id_media_id_fk"; + ALTER TABLE "videos" DROP CONSTRAINT IF EXISTS "videos_category_id_video_categories_id_fk"; + ALTER TABLE "videos" DROP CONSTRAINT IF EXISTS "videos_thumbnail_id_media_id_fk"; + ALTER TABLE "videos" DROP CONSTRAINT IF EXISTS "videos_video_file_id_media_id_fk"; + ALTER TABLE "videos" DROP CONSTRAINT IF EXISTS "videos_tenant_id_tenants_id_fk"; + ALTER TABLE "video_categories_locales" DROP CONSTRAINT IF EXISTS "video_categories_locales_parent_id_fk"; + ALTER TABLE "video_categories" DROP CONSTRAINT IF EXISTS "video_categories_cover_image_id_media_id_fk"; + ALTER TABLE "video_categories" DROP CONSTRAINT IF EXISTS "video_categories_tenant_id_tenants_id_fk"; + + -- Drop video block extended columns + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "style_border"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "style_shadow"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "style_rounded"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "embed_options_privacy_mode"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "embed_options_show_related"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "playback_start_time"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "playback_playsinline"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "playback_controls"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "playback_loop"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "playback_muted"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "playback_autoplay"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "alignment"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "size"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "thumbnail_id"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "video_file_id"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "video_from_library_id"; + ALTER TABLE "pages_blocks_video_block" DROP COLUMN IF EXISTS "source_type"; + + -- Drop posts featured video columns + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_muted"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_autoplay"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_uploaded_video_id"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_embed_url"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_video_id"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_source"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_replace_image"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_enabled"; + + -- Drop tables + DROP TABLE IF EXISTS "videos_rels"; + DROP TABLE IF EXISTS "videos_locales"; + DROP TABLE IF EXISTS "videos"; + DROP TABLE IF EXISTS "video_categories_locales"; + DROP TABLE IF EXISTS "video_categories"; + + -- Drop enums + DROP TYPE IF EXISTS "public"."enum_pages_blocks_video_block_style_shadow"; + DROP TYPE IF EXISTS "public"."enum_pages_blocks_video_block_style_rounded"; + DROP TYPE IF EXISTS "public"."enum_pages_blocks_video_block_alignment"; + DROP TYPE IF EXISTS "public"."enum_pages_blocks_video_block_size"; + DROP TYPE IF EXISTS "public"."enum_pages_blocks_video_block_aspect_ratio"; + DROP TYPE IF EXISTS "public"."enum_pages_blocks_video_block_source_type"; + DROP TYPE IF EXISTS "public"."enum_posts_featured_video_source"; + DROP TYPE IF EXISTS "public"."enum_videos_status"; + DROP TYPE IF EXISTS "public"."enum_videos_aspect_ratio"; + DROP TYPE IF EXISTS "public"."enum_videos_video_type"; + DROP TYPE IF EXISTS "public"."enum_videos_source"; + + `); +} diff --git a/src/migrations/20251216_080000_posts_featured_video_processed_fields.ts b/src/migrations/20251216_080000_posts_featured_video_processed_fields.ts new file mode 100644 index 0000000..da02e1e --- /dev/null +++ b/src/migrations/20251216_080000_posts_featured_video_processed_fields.ts @@ -0,0 +1,28 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Migration: Add processed fields for Posts featuredVideo + * + * Adds columns for storing processed video metadata: + * - processedEmbedUrl: Generated embed URL with privacy mode + * - extractedVideoId: Extracted video ID (e.g. YouTube video ID) + * - platform: Detected platform (youtube, vimeo, etc.) + * - thumbnailUrl: Auto-generated thumbnail URL + */ +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_processed_embed_url" varchar; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_extracted_video_id" varchar; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_platform" varchar; + ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "featured_video_thumbnail_url" varchar; + `) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_thumbnail_url"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_platform"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_extracted_video_id"; + ALTER TABLE "posts" DROP COLUMN IF EXISTS "featured_video_processed_embed_url"; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 0524f14..ec9d579 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -18,6 +18,8 @@ import * as migration_20251213_220000_blogging_collections from './20251213_2200 import * as migration_20251213_230000_team_extensions from './20251213_230000_team_extensions'; import * as migration_20251214_000000_add_priority_collections from './20251214_000000_add_priority_collections'; import * as migration_20251214_010000_tenant_specific_collections from './20251214_010000_tenant_specific_collections'; +import * as migration_20251216_073000_add_video_collections from './20251216_073000_add_video_collections'; +import * as migration_20251216_080000_posts_featured_video_processed_fields from './20251216_080000_posts_featured_video_processed_fields'; export const migrations = [ { @@ -120,4 +122,14 @@ export const migrations = [ down: migration_20251214_010000_tenant_specific_collections.down, name: '20251214_010000_tenant_specific_collections', }, + { + up: migration_20251216_073000_add_video_collections.up, + down: migration_20251216_073000_add_video_collections.down, + name: '20251216_073000_add_video_collections', + }, + { + up: migration_20251216_080000_posts_featured_video_processed_fields.up, + down: migration_20251216_080000_posts_featured_video_processed_fields.down, + name: '20251216_080000_posts_featured_video_processed_fields', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 914e2fa..9237c89 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -82,6 +82,8 @@ export interface Config { 'newsletter-subscribers': NewsletterSubscriber; 'portfolio-categories': PortfolioCategory; portfolios: Portfolio; + 'video-categories': VideoCategory; + videos: Video; 'product-categories': ProductCategory; products: Product; timelines: Timeline; @@ -127,6 +129,8 @@ export interface Config { 'newsletter-subscribers': NewsletterSubscribersSelect | NewsletterSubscribersSelect; 'portfolio-categories': PortfolioCategoriesSelect | PortfolioCategoriesSelect; portfolios: PortfoliosSelect | PortfoliosSelect; + 'video-categories': VideoCategoriesSelect | VideoCategoriesSelect; + videos: VideosSelect | VideosSelect; 'product-categories': ProductCategoriesSelect | ProductCategoriesSelect; products: ProductsSelect | ProductsSelect; timelines: TimelinesSelect | TimelinesSelect; @@ -777,11 +781,76 @@ export interface Page { } | { /** - * YouTube oder Vimeo URL + * Woher soll das Video eingebunden werden? + */ + sourceType: 'embed' | 'upload' | 'library' | 'external'; + /** + * Video aus der Video-Bibliothek auswählen + */ + videoFromLibrary?: (number | null) | Video; + /** + * YouTube, Vimeo oder externe Video-URL + */ + videoUrl?: string | null; + /** + * MP4, WebM oder andere Video-Dateien hochladen + */ + videoFile?: (number | null) | Media; + /** + * Eigenes Thumbnail (optional, bei YouTube wird automatisch eines verwendet) + */ + thumbnail?: (number | null) | Media; + /** + * Bildunterschrift unter dem Video */ - videoUrl: string; caption?: string | null; - aspectRatio?: ('16:9' | '4:3' | '1:1') | null; + aspectRatio?: ('16:9' | '4:3' | '1:1' | '9:16' | '21:9') | null; + /** + * Breite des Video-Containers + */ + size?: ('full' | 'large' | 'medium' | 'small') | null; + alignment?: ('left' | 'center' | 'right') | null; + playback?: { + /** + * Video automatisch starten (erfordert meist Mute) + */ + autoplay?: boolean | null; + /** + * Video stumm abspielen + */ + muted?: boolean | null; + /** + * Video in Endlosschleife abspielen + */ + loop?: boolean | null; + /** + * Video-Controls anzeigen + */ + controls?: boolean | null; + /** + * Auf Mobile inline statt Vollbild abspielen + */ + playsinline?: boolean | null; + /** + * Video ab dieser Sekunde starten + */ + startTime?: number | null; + }; + embedOptions?: { + /** + * Am Ende ähnliche Videos von YouTube/Vimeo anzeigen + */ + showRelated?: boolean | null; + /** + * YouTube-nocookie.com verwenden (DSGVO-konformer) + */ + privacyMode?: boolean | null; + }; + style?: { + rounded?: ('none' | 'sm' | 'md' | 'lg' | 'xl') | null; + shadow?: ('none' | 'sm' | 'md' | 'lg' | 'xl') | null; + border?: boolean | null; + }; id?: string | null; blockName?: string | null; blockType: 'video-block'; @@ -2687,82 +2756,302 @@ export interface Page { createdAt: string; } /** + * Video-Bibliothek für YouTube/Vimeo Embeds und hochgeladene Videos + * * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "categories". + * via the `definition` "videos". */ -export interface Category { +export interface Video { + id: number; + tenant?: (number | null) | Tenant; + /** + * Titel des Videos + */ + title: string; + /** + * URL-freundlicher Name (z.B. "produkt-tutorial") + */ + slug: string; + /** + * Ausführliche Beschreibung des Videos + */ + description?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + /** + * Kurzbeschreibung für Übersichten (max. 300 Zeichen) + */ + excerpt?: string | null; + /** + * Woher stammt das Video? + */ + source: 'youtube' | 'vimeo' | 'upload' | 'external'; + /** + * MP4, WebM oder andere Video-Dateien + */ + videoFile?: (number | null) | Media; + /** + * YouTube/Vimeo URL oder direkte Video-URL + */ + embedUrl?: string | null; + /** + * Wird automatisch aus der URL extrahiert + */ + videoId?: string | null; + /** + * Eigenes Thumbnail (bei YouTube wird automatisch eins verwendet falls leer) + */ + thumbnail?: (number | null) | Media; + /** + * Video-Dauer (z.B. "2:30" oder "1:02:30") + */ + duration?: string | null; + /** + * Automatisch berechnet + */ + durationSeconds?: number | null; + /** + * Primäre Video-Kategorie + */ + category?: (number | null) | VideoCategory; + /** + * Schlagwörter für bessere Auffindbarkeit + */ + tags?: (number | Tag)[] | null; + /** + * Art des Videos + */ + videoType?: + | ('tutorial' | 'product' | 'testimonial' | 'explainer' | 'webinar' | 'interview' | 'event' | 'trailer' | 'other') + | null; + playback?: { + /** + * Video automatisch starten (Browser blockieren oft ohne Mute) + */ + autoplay?: boolean | null; + /** + * Video stumm abspielen (erforderlich für Autoplay in Browsern) + */ + muted?: boolean | null; + /** + * Video in Endlosschleife abspielen + */ + loop?: boolean | null; + /** + * Video-Controls anzeigen + */ + controls?: boolean | null; + /** + * Video ab dieser Sekunde starten + */ + startTime?: number | null; + }; + /** + * Anzeigeverhältnis des Videos + */ + aspectRatio?: ('16:9' | '4:3' | '1:1' | '9:16' | '21:9') | null; + status?: ('draft' | 'published' | 'archived') | null; + /** + * Als Featured Video markieren + */ + isFeatured?: boolean | null; + publishedAt?: string | null; + /** + * Weitere Videos zu diesem Thema + */ + relatedVideos?: (number | Video)[] | null; + /** + * Blog-Beiträge zu diesem Video + */ + relatedPosts?: (number | Post)[] | null; + /** + * Vollständiges Transkript für SEO und Barrierefreiheit + */ + transcript?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + seo?: { + /** + * SEO-Titel (falls abweichend vom Video-Titel) + */ + metaTitle?: string | null; + /** + * SEO-Beschreibung (max. 160 Zeichen) + */ + metaDescription?: string | null; + /** + * Bild für Social Media Shares (Fallback: Thumbnail) + */ + ogImage?: (number | null) | Media; + }; + updatedAt: string; + createdAt: string; +} +/** + * Kategorien für Video-Bibliothek (z.B. Tutorials, Produktvideos, Testimonials) + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "video-categories". + */ +export interface VideoCategory { + id: number; + tenant?: (number | null) | Tenant; + /** + * z.B. "Tutorials", "Produktvideos", "Webinare" + */ + name: string; + /** + * URL-freundlicher Name (z.B. "tutorials", "produktvideos") + */ + slug: string; + /** + * Kurzbeschreibung der Kategorie für SEO und Übersichten + */ + description?: string | null; + /** + * Icon-Name (z.B. Lucide Icon wie "play-circle", "video", "film") + */ + icon?: string | null; + /** + * Repräsentatives Bild für die Kategorieübersicht + */ + coverImage?: (number | null) | Media; + /** + * Niedrigere Zahlen erscheinen zuerst + */ + order?: number | null; + /** + * Inaktive Kategorien werden nicht angezeigt + */ + isActive?: boolean | null; + updatedAt: string; + createdAt: string; +} +/** + * Schlagwörter für Blog-Posts + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tags". + */ +export interface Tag { id: number; tenant?: (number | null) | Tenant; name: string; + /** + * URL-freundlicher Identifier (z.B. "javascript") + */ slug: string; + /** + * Optionale Beschreibung für Tag-Archivseiten + */ description?: string | null; + /** + * Optionale Farbe für Tag-Badge (z.B. "#3B82F6" oder "blue") + */ + color?: string | null; updatedAt: string; createdAt: string; } /** - * Kundenstimmen und Bewertungen - * * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "testimonials". + * via the `definition` "posts". */ -export interface Testimonial { +export interface Post { id: number; tenant?: (number | null) | Tenant; + title: string; /** - * Die Aussage des Kunden + * URL-Pfad (z.B. "mein-beitrag" / "my-post") */ - quote: string; - author: string; + slug: string; /** - * z.B. "Patient", "Geschäftsführer", "Marketing Manager" + * Art des Beitrags */ - role?: string | null; - company?: string | null; + type: 'blog' | 'news' | 'press' | 'announcement'; /** - * Portrait-Foto (empfohlen: quadratisch, min. 200x200px) + * Auf Startseite/oben anzeigen */ - image?: (number | null) | Media; + isFeatured?: boolean | null; /** - * Optional: Sterne-Bewertung + * Für Übersichten und SEO (max. 300 Zeichen). Wird automatisch aus Content generiert, falls leer. */ - rating?: number | null; + excerpt?: string | null; + featuredImage?: (number | null) | Media; /** - * z.B. "Google Reviews", "Trustpilot", "Persönlich" + * Optional: Video als Hero-Element für diesen Beitrag */ - source?: string | null; - /** - * URL zur Original-Bewertung (falls öffentlich) - */ - sourceUrl?: string | null; - date?: string | null; - /** - * Inaktive Testimonials werden nicht angezeigt - */ - isActive?: boolean | null; - /** - * Niedrigere Zahlen werden zuerst angezeigt - */ - order?: number | null; - updatedAt: string; - createdAt: string; -} -/** - * Häufig gestellte Fragen (FAQ) - * - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "faqs". - */ -export interface Faq { - id: number; - tenant?: (number | null) | Tenant; - /** - * Die Frage, die beantwortet wird - */ - question: string; - /** - * Die ausführliche Antwort auf die Frage - */ - answer: { + featuredVideo?: { + /** + * Video als primäres Medienelement verwenden + */ + enabled?: boolean | null; + /** + * Video statt Beitragsbild im Hero-Bereich anzeigen + */ + replaceImage?: boolean | null; + source?: ('library' | 'embed' | 'upload') | null; + /** + * Video aus der Video-Bibliothek auswählen + */ + video?: (number | null) | Video; + /** + * YouTube oder Vimeo URL + */ + embedUrl?: string | null; + /** + * MP4, WebM oder andere Video-Dateien + */ + uploadedVideo?: (number | null) | Media; + /** + * Video automatisch starten (erfordert Mute) + */ + autoplay?: boolean | null; + /** + * Video stumm abspielen (empfohlen für Autoplay) + */ + muted?: boolean | null; + /** + * Automatisch generierte Embed-URL mit Privacy-Mode + */ + processedEmbedUrl?: string | null; + /** + * Extrahierte Video-ID (z.B. YouTube Video-ID) + */ + extractedVideoId?: string | null; + /** + * Erkannte Plattform (youtube, vimeo, etc.) + */ + platform?: string | null; + /** + * Auto-generierte Thumbnail-URL + */ + thumbnailUrl?: string | null; + }; + content: { root: { type: string; children: { @@ -2777,34 +3066,143 @@ export interface Faq { }; [k: string]: unknown; }; + categories?: (number | Category)[] | null; /** - * Kurzfassung der Antwort als reiner Text für Schema.org Structured Data. Falls leer, wird die Rich-Text-Antwort verwendet. + * Schlagwörter für bessere Auffindbarkeit */ - answerPlainText?: string | null; + tags?: (number | Tag)[] | null; /** - * Optionale Kategorie zur Gruppierung (z.B. "Allgemein", "Preise", "Lieferung") + * Hauptautor des Beitrags */ - category?: string | null; + author?: (number | null) | Author; /** - * Optionaler Icon-Name (z.B. "question-circle", "info") + * Weitere beteiligte Autoren */ - icon?: string | null; + coAuthors?: (number | Author)[] | null; /** - * Andere FAQs die thematisch zusammenhängen + * Freitext-Autor für ältere Beiträge ohne Autoren-Eintrag */ - relatedFAQs?: (number | Faq)[] | null; + authorLegacy?: string | null; /** - * Inaktive FAQs werden nicht angezeigt + * Wird automatisch berechnet + */ + readingTime?: number | null; + status?: ('draft' | 'published' | 'archived') | null; + publishedAt?: string | null; + seo?: { + metaTitle?: string | null; + metaDescription?: string | null; + ogImage?: (number | null) | Media; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "categories". + */ +export interface Category { + id: number; + tenant?: (number | null) | Tenant; + name: string; + slug: string; + description?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * Blog-Autoren und Gastautoren + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "authors". + */ +export interface Author { + id: number; + tenant?: (number | null) | Tenant; + /** + * Anzeigename des Autors + */ + name: string; + /** + * URL-freundlicher Identifier (z.B. "max-mustermann") + */ + slug: string; + /** + * Avatar/Profilbild (empfohlen: quadratisch, min. 200x200px) + */ + avatar?: (number | null) | Media; + /** + * Ausführliche Biografie für Autorenseite + */ + bio?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + /** + * Ein bis zwei Sätze für Anzeige unter Artikeln + */ + bioShort?: string | null; + /** + * z.B. "Senior Editor", "Gastautor", "Chefredakteur" + */ + title?: string | null; + /** + * Öffentliche Kontakt-E-Mail (optional) + */ + email?: string | null; + /** + * Persönliche Website oder Blog + */ + website?: string | null; + social?: { + /** + * Twitter-Handle ohne @ (z.B. "maxmustermann") + */ + twitter?: string | null; + /** + * LinkedIn-Profil-URL oder Username + */ + linkedin?: string | null; + /** + * GitHub-Username + */ + github?: string | null; + /** + * Instagram-Handle ohne @ + */ + instagram?: string | null; + }; + /** + * Optional: Verknüpfung mit Team-Eintrag + */ + linkedTeam?: (number | null) | Team; + /** + * Optional: Verknüpfung mit Login-User + */ + linkedUser?: (number | null) | User; + /** + * Inaktive Autoren erscheinen nicht in Listen */ isActive?: boolean | null; /** - * Hervorgehobene FAQs werden prominent angezeigt + * Markierung für Gastautoren */ - isFeatured?: boolean | null; + isGuest?: boolean | null; /** - * Niedrigere Zahlen werden zuerst angezeigt + * Für besondere Darstellung auf Autorenseite */ - order?: number | null; + featured?: boolean | null; updatedAt: string; createdAt: string; } @@ -2934,6 +3332,115 @@ export interface Team { updatedAt: string; createdAt: string; } +/** + * Kundenstimmen und Bewertungen + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "testimonials". + */ +export interface Testimonial { + id: number; + tenant?: (number | null) | Tenant; + /** + * Die Aussage des Kunden + */ + quote: string; + author: string; + /** + * z.B. "Patient", "Geschäftsführer", "Marketing Manager" + */ + role?: string | null; + company?: string | null; + /** + * Portrait-Foto (empfohlen: quadratisch, min. 200x200px) + */ + image?: (number | null) | Media; + /** + * Optional: Sterne-Bewertung + */ + rating?: number | null; + /** + * z.B. "Google Reviews", "Trustpilot", "Persönlich" + */ + source?: string | null; + /** + * URL zur Original-Bewertung (falls öffentlich) + */ + sourceUrl?: string | null; + date?: string | null; + /** + * Inaktive Testimonials werden nicht angezeigt + */ + isActive?: boolean | null; + /** + * Niedrigere Zahlen werden zuerst angezeigt + */ + order?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * Häufig gestellte Fragen (FAQ) + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "faqs". + */ +export interface Faq { + id: number; + tenant?: (number | null) | Tenant; + /** + * Die Frage, die beantwortet wird + */ + question: string; + /** + * Die ausführliche Antwort auf die Frage + */ + answer: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + }; + /** + * Kurzfassung der Antwort als reiner Text für Schema.org Structured Data. Falls leer, wird die Rich-Text-Antwort verwendet. + */ + answerPlainText?: string | null; + /** + * Optionale Kategorie zur Gruppierung (z.B. "Allgemein", "Preise", "Lieferung") + */ + category?: string | null; + /** + * Optionaler Icon-Name (z.B. "question-circle", "info") + */ + icon?: string | null; + /** + * Andere FAQs die thematisch zusammenhängen + */ + relatedFAQs?: (number | Faq)[] | null; + /** + * Inaktive FAQs werden nicht angezeigt + */ + isActive?: boolean | null; + /** + * Hervorgehobene FAQs werden prominent angezeigt + */ + isFeatured?: boolean | null; + /** + * Niedrigere Zahlen werden zuerst angezeigt + */ + order?: number | null; + updatedAt: string; + createdAt: string; +} /** * Kategorien für Leistungen/Services * @@ -3171,198 +3678,6 @@ export interface Service { updatedAt: string; createdAt: string; } -/** - * Blog-Autoren und Gastautoren - * - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "authors". - */ -export interface Author { - id: number; - tenant?: (number | null) | Tenant; - /** - * Anzeigename des Autors - */ - name: string; - /** - * URL-freundlicher Identifier (z.B. "max-mustermann") - */ - slug: string; - /** - * Avatar/Profilbild (empfohlen: quadratisch, min. 200x200px) - */ - avatar?: (number | null) | Media; - /** - * Ausführliche Biografie für Autorenseite - */ - bio?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; - /** - * Ein bis zwei Sätze für Anzeige unter Artikeln - */ - bioShort?: string | null; - /** - * z.B. "Senior Editor", "Gastautor", "Chefredakteur" - */ - title?: string | null; - /** - * Öffentliche Kontakt-E-Mail (optional) - */ - email?: string | null; - /** - * Persönliche Website oder Blog - */ - website?: string | null; - social?: { - /** - * Twitter-Handle ohne @ (z.B. "maxmustermann") - */ - twitter?: string | null; - /** - * LinkedIn-Profil-URL oder Username - */ - linkedin?: string | null; - /** - * GitHub-Username - */ - github?: string | null; - /** - * Instagram-Handle ohne @ - */ - instagram?: string | null; - }; - /** - * Optional: Verknüpfung mit Team-Eintrag - */ - linkedTeam?: (number | null) | Team; - /** - * Optional: Verknüpfung mit Login-User - */ - linkedUser?: (number | null) | User; - /** - * Inaktive Autoren erscheinen nicht in Listen - */ - isActive?: boolean | null; - /** - * Markierung für Gastautoren - */ - isGuest?: boolean | null; - /** - * Für besondere Darstellung auf Autorenseite - */ - featured?: boolean | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts". - */ -export interface Post { - id: number; - tenant?: (number | null) | Tenant; - title: string; - /** - * URL-Pfad (z.B. "mein-beitrag" / "my-post") - */ - slug: string; - /** - * Art des Beitrags - */ - type: 'blog' | 'news' | 'press' | 'announcement'; - /** - * Auf Startseite/oben anzeigen - */ - isFeatured?: boolean | null; - /** - * Für Übersichten und SEO (max. 300 Zeichen). Wird automatisch aus Content generiert, falls leer. - */ - excerpt?: string | null; - featuredImage?: (number | null) | Media; - content: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - }; - categories?: (number | Category)[] | null; - /** - * Schlagwörter für bessere Auffindbarkeit - */ - tags?: (number | Tag)[] | null; - /** - * Hauptautor des Beitrags - */ - author?: (number | null) | Author; - /** - * Weitere beteiligte Autoren - */ - coAuthors?: (number | Author)[] | null; - /** - * Freitext-Autor für ältere Beiträge ohne Autoren-Eintrag - */ - authorLegacy?: string | null; - /** - * Wird automatisch berechnet - */ - readingTime?: number | null; - status?: ('draft' | 'published' | 'archived') | null; - publishedAt?: string | null; - seo?: { - metaTitle?: string | null; - metaDescription?: string | null; - ogImage?: (number | null) | Media; - }; - updatedAt: string; - createdAt: string; -} -/** - * Schlagwörter für Blog-Posts - * - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "tags". - */ -export interface Tag { - id: number; - tenant?: (number | null) | Tenant; - name: string; - /** - * URL-freundlicher Identifier (z.B. "javascript") - */ - slug: string; - /** - * Optionale Beschreibung für Tag-Archivseiten - */ - description?: string | null; - /** - * Optionale Farbe für Tag-Badge (z.B. "#3B82F6" oder "blue") - */ - color?: string | null; - updatedAt: string; - createdAt: string; -} /** * Firmenstandorte und Niederlassungen * @@ -5938,6 +6253,14 @@ export interface PayloadLockedDocument { relationTo: 'portfolios'; value: number | Portfolio; } | null) + | ({ + relationTo: 'video-categories'; + value: number | VideoCategory; + } | null) + | ({ + relationTo: 'videos'; + value: number | Video; + } | null) | ({ relationTo: 'product-categories'; value: number | ProductCategory; @@ -6590,9 +6913,38 @@ export interface PagesSelect { 'video-block'?: | T | { + sourceType?: T; + videoFromLibrary?: T; videoUrl?: T; + videoFile?: T; + thumbnail?: T; caption?: T; aspectRatio?: T; + size?: T; + alignment?: T; + playback?: + | T + | { + autoplay?: T; + muted?: T; + loop?: T; + controls?: T; + playsinline?: T; + startTime?: T; + }; + embedOptions?: + | T + | { + showRelated?: T; + privacyMode?: T; + }; + style?: + | T + | { + rounded?: T; + shadow?: T; + border?: T; + }; id?: T; blockName?: T; }; @@ -8068,6 +8420,22 @@ export interface PostsSelect { isFeatured?: T; excerpt?: T; featuredImage?: T; + featuredVideo?: + | T + | { + enabled?: T; + replaceImage?: T; + source?: T; + video?: T; + embedUrl?: T; + uploadedVideo?: T; + autoplay?: T; + muted?: T; + processedEmbedUrl?: T; + extractedVideoId?: T; + platform?: T; + thumbnailUrl?: T; + }; content?: T; categories?: T; tags?: T; @@ -8369,6 +8737,68 @@ export interface PortfoliosSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "video-categories_select". + */ +export interface VideoCategoriesSelect { + tenant?: T; + name?: T; + slug?: T; + description?: T; + icon?: T; + coverImage?: T; + order?: T; + isActive?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "videos_select". + */ +export interface VideosSelect { + tenant?: T; + title?: T; + slug?: T; + description?: T; + excerpt?: T; + source?: T; + videoFile?: T; + embedUrl?: T; + videoId?: T; + thumbnail?: T; + duration?: T; + durationSeconds?: T; + category?: T; + tags?: T; + videoType?: T; + playback?: + | T + | { + autoplay?: T; + muted?: T; + loop?: T; + controls?: T; + startTime?: T; + }; + aspectRatio?: T; + status?: T; + isFeatured?: T; + publishedAt?: T; + relatedVideos?: T; + relatedPosts?: T; + transcript?: T; + seo?: + | T + | { + metaTitle?: T; + metaDescription?: T; + ogImage?: T; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "product-categories_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index b1c285a..cedf595 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -34,6 +34,10 @@ import { NewsletterSubscribers } from './collections/NewsletterSubscribers' import { PortfolioCategories } from './collections/PortfolioCategories' import { Portfolios } from './collections/Portfolios' +// Video Collections +import { VideoCategories } from './collections/VideoCategories' +import { Videos } from './collections/Videos' + // Product Collections import { ProductCategories } from './collections/ProductCategories' import { Products } from './collections/Products' @@ -171,6 +175,9 @@ export default buildConfig({ // Portfolio PortfolioCategories, Portfolios, + // Videos + VideoCategories, + Videos, // Products ProductCategories, Products, @@ -234,6 +241,9 @@ export default buildConfig({ // Portfolio Collections 'portfolio-categories': {}, portfolios: {}, + // Video Collections + 'video-categories': {}, + videos: {}, // Product Collections 'product-categories': {}, products: {}, diff --git a/tests/int/videos.int.spec.ts b/tests/int/videos.int.spec.ts new file mode 100644 index 0000000..5862458 --- /dev/null +++ b/tests/int/videos.int.spec.ts @@ -0,0 +1,298 @@ +import { getPayload, Payload } from 'payload' +import config from '@/payload.config' + +import { describe, it, beforeAll, afterAll, expect } from 'vitest' + +let payload: Payload +let testTenantId: number +let testVideoId: number +let testCategoryId: number + +describe('Videos Collection API', () => { + beforeAll(async () => { + const payloadConfig = await config + payload = await getPayload({ config: payloadConfig }) + + // Find or use existing tenant for testing + const tenants = await payload.find({ + collection: 'tenants', + limit: 1, + }) + + if (tenants.docs.length > 0) { + testTenantId = tenants.docs[0].id as number + } else { + // Create a test tenant if none exists + const tenant = await payload.create({ + collection: 'tenants', + data: { + name: 'Test Tenant for Videos', + slug: 'test-videos-tenant', + domains: [{ domain: 'test-videos.local' }], + }, + }) + testTenantId = tenant.id as number + } + }) + + afterAll(async () => { + // Cleanup: Delete test video and category if created + if (testVideoId) { + try { + await payload.delete({ + collection: 'videos', + id: testVideoId, + }) + } catch { + // Ignore if already deleted + } + } + if (testCategoryId) { + try { + await payload.delete({ + collection: 'video-categories', + id: testCategoryId, + }) + } catch { + // Ignore if already deleted + } + } + }) + + describe('VideoCategories CRUD', () => { + it('creates a video category', async () => { + const category = await payload.create({ + collection: 'video-categories', + data: { + name: 'Test Category', + slug: 'test-category-' + Date.now(), + tenant: testTenantId, + isActive: true, + }, + }) + + expect(category).toBeDefined() + expect(category.id).toBeDefined() + expect(category.name).toBe('Test Category') + testCategoryId = category.id as number + }) + + it('finds video categories', async () => { + const categories = await payload.find({ + collection: 'video-categories', + where: { + tenant: { equals: testTenantId }, + }, + }) + + expect(categories).toBeDefined() + expect(categories.docs).toBeInstanceOf(Array) + expect(categories.docs.length).toBeGreaterThan(0) + }) + + it('updates a video category', async () => { + const updated = await payload.update({ + collection: 'video-categories', + id: testCategoryId, + data: { + name: 'Updated Category Name', + }, + }) + + expect(updated.name).toBe('Updated Category Name') + }) + }) + + describe('Videos CRUD', () => { + it('creates a video with YouTube embed', async () => { + const video = await payload.create({ + collection: 'videos', + data: { + title: 'Test Video', + slug: 'test-video-' + Date.now(), + tenant: testTenantId, + source: 'youtube', + embedUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + status: 'draft', + }, + }) + + expect(video).toBeDefined() + expect(video.id).toBeDefined() + expect(video.title).toBe('Test Video') + expect(video.source).toBe('youtube') + // Check that videoId was extracted by hook + expect(video.videoId).toBe('dQw4w9WgXcQ') + testVideoId = video.id as number + }) + + it('creates a video with Vimeo embed', async () => { + const video = await payload.create({ + collection: 'videos', + data: { + title: 'Test Vimeo Video', + slug: 'test-vimeo-video-' + Date.now(), + tenant: testTenantId, + source: 'vimeo', + embedUrl: 'https://vimeo.com/76979871', + status: 'draft', + }, + }) + + expect(video).toBeDefined() + expect(video.videoId).toBe('76979871') + + // Cleanup this extra video + await payload.delete({ + collection: 'videos', + id: video.id, + }) + }) + + it('finds videos by tenant', async () => { + const videos = await payload.find({ + collection: 'videos', + where: { + tenant: { equals: testTenantId }, + }, + }) + + expect(videos).toBeDefined() + expect(videos.docs).toBeInstanceOf(Array) + expect(videos.docs.length).toBeGreaterThan(0) + }) + + it('finds videos by status', async () => { + const videos = await payload.find({ + collection: 'videos', + where: { + and: [{ tenant: { equals: testTenantId } }, { status: { equals: 'draft' } }], + }, + }) + + expect(videos).toBeDefined() + expect(videos.docs.every((v) => v.status === 'draft')).toBe(true) + }) + + it('updates a video', async () => { + const updated = await payload.update({ + collection: 'videos', + id: testVideoId, + data: { + title: 'Updated Video Title', + status: 'published', + }, + }) + + expect(updated.title).toBe('Updated Video Title') + expect(updated.status).toBe('published') + }) + + it('associates video with category', async () => { + const updated = await payload.update({ + collection: 'videos', + id: testVideoId, + data: { + category: testCategoryId, + }, + }) + + expect(updated.category).toBeDefined() + }) + + it('finds video by slug', async () => { + // First get the video to know its slug + const video = await payload.findByID({ + collection: 'videos', + id: testVideoId, + }) + + const found = await payload.find({ + collection: 'videos', + where: { + and: [{ tenant: { equals: testTenantId } }, { slug: { equals: video.slug } }], + }, + }) + + expect(found.docs.length).toBe(1) + expect(found.docs[0].id).toBe(testVideoId) + }) + }) + + describe('Slug Validation', () => { + it('prevents duplicate slugs within same tenant', async () => { + // Get the existing video's slug + const existingVideo = await payload.findByID({ + collection: 'videos', + id: testVideoId, + }) + + // Try to create another video with the same slug + await expect( + payload.create({ + collection: 'videos', + data: { + title: 'Duplicate Slug Video', + slug: existingVideo.slug, + tenant: testTenantId, + source: 'youtube', + embedUrl: 'https://www.youtube.com/watch?v=abc123', + status: 'draft', + }, + }) + ).rejects.toThrow() + }) + + it('prevents duplicate category slugs within same tenant', async () => { + // Get the existing category's slug + const existingCategory = await payload.findByID({ + collection: 'video-categories', + id: testCategoryId, + }) + + // Try to create another category with the same slug + await expect( + payload.create({ + collection: 'video-categories', + data: { + name: 'Duplicate Category', + slug: existingCategory.slug, + tenant: testTenantId, + }, + }) + ).rejects.toThrow() + }) + }) + + describe('Video Deletion', () => { + it('deletes a video', async () => { + const deleted = await payload.delete({ + collection: 'videos', + id: testVideoId, + }) + + expect(deleted.id).toBe(testVideoId) + + // Verify it's gone + const found = await payload.find({ + collection: 'videos', + where: { + id: { equals: testVideoId }, + }, + }) + + expect(found.docs.length).toBe(0) + testVideoId = 0 // Mark as deleted so afterAll doesn't try again + }) + + it('deletes a video category', async () => { + const deleted = await payload.delete({ + collection: 'video-categories', + id: testCategoryId, + }) + + expect(deleted.id).toBe(testCategoryId) + testCategoryId = 0 // Mark as deleted + }) + }) +}) diff --git a/tests/unit/video/video-utils.unit.spec.ts b/tests/unit/video/video-utils.unit.spec.ts new file mode 100644 index 0000000..2d66eaf --- /dev/null +++ b/tests/unit/video/video-utils.unit.spec.ts @@ -0,0 +1,532 @@ +/** + * Video Utils Unit Tests + * + * Tests for the video utility module. + * Covers URL parsing, embed URL generation, duration formatting, and validation. + */ + +import { describe, it, expect } from 'vitest' +import { + parseVideoUrl, + generateEmbedUrl, + formatDuration, + parseDuration, + getAspectRatioClass, + extractVideoId, + isValidVideoUrl, + getVideoPlatform, + getVideoThumbnail, + validateVideoUrl, +} from '@/lib/video' + +describe('Video Utils', () => { + describe('parseVideoUrl', () => { + describe('YouTube URLs', () => { + it('parses standard watch URL', () => { + const result = parseVideoUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ') + + expect(result).not.toBeNull() + expect(result?.platform).toBe('youtube') + expect(result?.videoId).toBe('dQw4w9WgXcQ') + expect(result?.embedUrl).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ') + expect(result?.thumbnailUrl).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg') + }) + + it('parses short URL (youtu.be)', () => { + const result = parseVideoUrl('https://youtu.be/dQw4w9WgXcQ') + + expect(result?.platform).toBe('youtube') + expect(result?.videoId).toBe('dQw4w9WgXcQ') + }) + + it('parses embed URL', () => { + const result = parseVideoUrl('https://www.youtube.com/embed/dQw4w9WgXcQ') + + expect(result?.platform).toBe('youtube') + expect(result?.videoId).toBe('dQw4w9WgXcQ') + }) + + it('parses youtube-nocookie URL', () => { + const result = parseVideoUrl('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ') + + expect(result?.platform).toBe('youtube') + expect(result?.videoId).toBe('dQw4w9WgXcQ') + }) + + it('parses shorts URL', () => { + const result = parseVideoUrl('https://www.youtube.com/shorts/dQw4w9WgXcQ') + + expect(result?.platform).toBe('youtube') + expect(result?.videoId).toBe('dQw4w9WgXcQ') + }) + + it('parses URL with additional parameters', () => { + const result = parseVideoUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120&list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf') + + expect(result?.platform).toBe('youtube') + expect(result?.videoId).toBe('dQw4w9WgXcQ') + }) + + it('handles URL without https://', () => { + const result = parseVideoUrl('youtube.com/watch?v=dQw4w9WgXcQ') + + expect(result?.platform).toBe('youtube') + expect(result?.videoId).toBe('dQw4w9WgXcQ') + }) + }) + + describe('Vimeo URLs', () => { + it('parses standard Vimeo URL', () => { + const result = parseVideoUrl('https://vimeo.com/123456789') + + expect(result?.platform).toBe('vimeo') + expect(result?.videoId).toBe('123456789') + expect(result?.embedUrl).toBe('https://player.vimeo.com/video/123456789') + expect(result?.thumbnailUrl).toBeNull() // Vimeo needs API call + }) + + it('parses player URL', () => { + const result = parseVideoUrl('https://player.vimeo.com/video/123456789') + + expect(result?.platform).toBe('vimeo') + expect(result?.videoId).toBe('123456789') + }) + + it('parses channel URL', () => { + const result = parseVideoUrl('https://vimeo.com/channels/staffpicks/123456789') + + expect(result?.platform).toBe('vimeo') + expect(result?.videoId).toBe('123456789') + }) + + it('parses groups URL', () => { + const result = parseVideoUrl('https://vimeo.com/groups/shortfilms/videos/123456789') + + expect(result?.platform).toBe('vimeo') + expect(result?.videoId).toBe('123456789') + }) + }) + + describe('External Video URLs', () => { + it('recognizes direct MP4 URL', () => { + const result = parseVideoUrl('https://example.com/video.mp4') + + expect(result?.platform).toBe('external') + expect(result?.videoId).toBeNull() + expect(result?.embedUrl).toBe('https://example.com/video.mp4') + }) + + it('recognizes WebM URL', () => { + const result = parseVideoUrl('https://example.com/video.webm') + + expect(result?.platform).toBe('external') + }) + + it('recognizes MOV URL', () => { + const result = parseVideoUrl('https://cdn.example.com/uploads/movie.mov') + + expect(result?.platform).toBe('external') + }) + }) + + describe('Edge Cases', () => { + it('returns null for empty string', () => { + expect(parseVideoUrl('')).toBeNull() + }) + + it('returns null for null input', () => { + expect(parseVideoUrl(null as unknown as string)).toBeNull() + }) + + it('returns null for undefined input', () => { + expect(parseVideoUrl(undefined as unknown as string)).toBeNull() + }) + + it('returns unknown for invalid URL', () => { + const result = parseVideoUrl('https://example.com/page') + + expect(result?.platform).toBe('unknown') + expect(result?.videoId).toBeNull() + expect(result?.embedUrl).toBeNull() + }) + + it('handles whitespace', () => { + const result = parseVideoUrl(' https://www.youtube.com/watch?v=dQw4w9WgXcQ ') + + expect(result?.platform).toBe('youtube') + expect(result?.videoId).toBe('dQw4w9WgXcQ') + }) + }) + }) + + describe('generateEmbedUrl', () => { + const youtubeInfo = { + platform: 'youtube' as const, + videoId: 'dQw4w9WgXcQ', + originalUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + embedUrl: 'https://www.youtube.com/embed/dQw4w9WgXcQ', + thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg', + } + + const vimeoInfo = { + platform: 'vimeo' as const, + videoId: '123456789', + originalUrl: 'https://vimeo.com/123456789', + embedUrl: 'https://player.vimeo.com/video/123456789', + thumbnailUrl: null, + } + + describe('YouTube', () => { + it('generates basic embed URL', () => { + const url = generateEmbedUrl(youtubeInfo) + + expect(url).toContain('youtube.com/embed/dQw4w9WgXcQ') + expect(url).toContain('modestbranding=1') + }) + + it('adds autoplay parameter', () => { + const url = generateEmbedUrl(youtubeInfo, { autoplay: true }) + + expect(url).toContain('autoplay=1') + }) + + it('adds mute parameter', () => { + const url = generateEmbedUrl(youtubeInfo, { muted: true }) + + expect(url).toContain('mute=1') + }) + + it('adds loop parameter with playlist', () => { + const url = generateEmbedUrl(youtubeInfo, { loop: true }) + + expect(url).toContain('loop=1') + expect(url).toContain('playlist=dQw4w9WgXcQ') + }) + + it('hides controls when specified', () => { + const url = generateEmbedUrl(youtubeInfo, { controls: false }) + + expect(url).toContain('controls=0') + }) + + it('adds start time', () => { + const url = generateEmbedUrl(youtubeInfo, { startTime: 120 }) + + expect(url).toContain('start=120') + }) + + it('uses privacy mode (youtube-nocookie)', () => { + const url = generateEmbedUrl(youtubeInfo, { privacyMode: true }) + + expect(url).toContain('youtube-nocookie.com') + expect(url).not.toContain('www.youtube.com') + }) + + it('disables related videos', () => { + const url = generateEmbedUrl(youtubeInfo, { showRelated: false }) + + expect(url).toContain('rel=0') + }) + + it('combines multiple options', () => { + const url = generateEmbedUrl(youtubeInfo, { + autoplay: true, + muted: true, + loop: true, + privacyMode: true, + startTime: 30, + }) + + expect(url).toContain('youtube-nocookie.com') + expect(url).toContain('autoplay=1') + expect(url).toContain('mute=1') + expect(url).toContain('loop=1') + expect(url).toContain('start=30') + }) + }) + + describe('Vimeo', () => { + it('generates basic embed URL', () => { + const url = generateEmbedUrl(vimeoInfo) + + expect(url).toBe('https://player.vimeo.com/video/123456789') + }) + + it('adds autoplay parameter', () => { + const url = generateEmbedUrl(vimeoInfo, { autoplay: true }) + + expect(url).toContain('autoplay=1') + }) + + it('adds muted parameter', () => { + const url = generateEmbedUrl(vimeoInfo, { muted: true }) + + expect(url).toContain('muted=1') + }) + + it('adds loop parameter', () => { + const url = generateEmbedUrl(vimeoInfo, { loop: true }) + + expect(url).toContain('loop=1') + }) + + it('adds start time as hash', () => { + const url = generateEmbedUrl(vimeoInfo, { startTime: 60 }) + + expect(url).toContain('#t=60s') + }) + }) + + describe('Edge Cases', () => { + it('returns null for null input', () => { + expect(generateEmbedUrl(null as never)).toBeNull() + }) + + it('returns null for video info without embed URL', () => { + expect(generateEmbedUrl({ ...youtubeInfo, embedUrl: null })).toBeNull() + }) + + it('floors start time to integer', () => { + const url = generateEmbedUrl(youtubeInfo, { startTime: 30.5 }) + + expect(url).toContain('start=30') + expect(url).not.toContain('start=30.5') + }) + }) + }) + + describe('formatDuration', () => { + it('formats seconds under a minute', () => { + expect(formatDuration(45)).toBe('0:45') + }) + + it('formats minutes and seconds', () => { + expect(formatDuration(150)).toBe('2:30') + }) + + it('formats hours, minutes, and seconds', () => { + expect(formatDuration(3723)).toBe('1:02:03') + }) + + it('pads single digits', () => { + expect(formatDuration(65)).toBe('1:05') + expect(formatDuration(3605)).toBe('1:00:05') + }) + + it('handles zero', () => { + expect(formatDuration(0)).toBe('0:00') + }) + + it('handles negative numbers', () => { + expect(formatDuration(-10)).toBe('0:00') + }) + + it('handles NaN', () => { + expect(formatDuration(NaN)).toBe('0:00') + }) + + it('handles non-number input', () => { + expect(formatDuration('invalid' as unknown as number)).toBe('0:00') + }) + }) + + describe('parseDuration', () => { + it('parses MM:SS format', () => { + expect(parseDuration('2:30')).toBe(150) + }) + + it('parses HH:MM:SS format', () => { + expect(parseDuration('1:02:30')).toBe(3750) + }) + + it('parses seconds only', () => { + expect(parseDuration('90')).toBe(90) + }) + + it('parses "Xh Ym Zs" format', () => { + expect(parseDuration('1h 30m 45s')).toBe(5445) + }) + + it('parses partial formats', () => { + expect(parseDuration('2h')).toBe(7200) + expect(parseDuration('30m')).toBe(1800) + expect(parseDuration('45s')).toBe(45) + expect(parseDuration('1h 30m')).toBe(5400) + }) + + it('handles whitespace', () => { + expect(parseDuration(' 2:30 ')).toBe(150) + }) + + it('handles empty string', () => { + expect(parseDuration('')).toBe(0) + }) + + it('handles null/undefined', () => { + expect(parseDuration(null as unknown as string)).toBe(0) + expect(parseDuration(undefined as unknown as string)).toBe(0) + }) + + it('handles invalid input', () => { + expect(parseDuration('invalid')).toBe(0) + }) + }) + + describe('getAspectRatioClass', () => { + it('returns aspect-video for 16:9', () => { + expect(getAspectRatioClass('16:9')).toBe('aspect-video') + }) + + it('returns correct class for 4:3', () => { + expect(getAspectRatioClass('4:3')).toBe('aspect-[4/3]') + }) + + it('returns aspect-square for 1:1', () => { + expect(getAspectRatioClass('1:1')).toBe('aspect-square') + }) + + it('returns correct class for 9:16', () => { + expect(getAspectRatioClass('9:16')).toBe('aspect-[9/16]') + }) + + it('returns correct class for 21:9', () => { + expect(getAspectRatioClass('21:9')).toBe('aspect-[21/9]') + }) + + it('returns default for unknown ratio', () => { + expect(getAspectRatioClass('unknown')).toBe('aspect-video') + }) + }) + + describe('extractVideoId', () => { + it('extracts YouTube video ID', () => { + expect(extractVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ') + }) + + it('extracts Vimeo video ID', () => { + expect(extractVideoId('https://vimeo.com/123456789')).toBe('123456789') + }) + + it('returns null for external URLs', () => { + expect(extractVideoId('https://example.com/video.mp4')).toBeNull() + }) + + it('returns null for invalid URLs', () => { + expect(extractVideoId('not-a-url')).toBeNull() + }) + }) + + describe('isValidVideoUrl', () => { + it('returns true for YouTube URLs', () => { + expect(isValidVideoUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true) + }) + + it('returns true for Vimeo URLs', () => { + expect(isValidVideoUrl('https://vimeo.com/123456789')).toBe(true) + }) + + it('returns true for direct video URLs', () => { + expect(isValidVideoUrl('https://example.com/video.mp4')).toBe(true) + }) + + it('returns false for non-video URLs', () => { + expect(isValidVideoUrl('https://example.com/page')).toBe(false) + }) + + it('returns false for empty string', () => { + expect(isValidVideoUrl('')).toBe(false) + }) + }) + + describe('getVideoPlatform', () => { + it('returns youtube for YouTube URLs', () => { + expect(getVideoPlatform('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe('youtube') + }) + + it('returns vimeo for Vimeo URLs', () => { + expect(getVideoPlatform('https://vimeo.com/123456789')).toBe('vimeo') + }) + + it('returns external for direct video URLs', () => { + expect(getVideoPlatform('https://example.com/video.mp4')).toBe('external') + }) + + it('returns unknown for non-video URLs', () => { + expect(getVideoPlatform('https://example.com/page')).toBe('unknown') + }) + }) + + describe('getVideoThumbnail', () => { + it('returns YouTube thumbnail in default quality', () => { + const url = getVideoThumbnail('https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'default') + + expect(url).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/default.jpg') + }) + + it('returns YouTube thumbnail in high quality', () => { + const url = getVideoThumbnail('https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'high') + + expect(url).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg') + }) + + it('returns YouTube thumbnail in max quality', () => { + const url = getVideoThumbnail('https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'max') + + expect(url).toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg') + }) + + it('returns null for Vimeo (requires API)', () => { + expect(getVideoThumbnail('https://vimeo.com/123456789')).toBeNull() + }) + + it('returns null for external URLs', () => { + expect(getVideoThumbnail('https://example.com/video.mp4')).toBeNull() + }) + + it('returns null for invalid URLs', () => { + expect(getVideoThumbnail('not-a-url')).toBeNull() + }) + }) + + describe('validateVideoUrl', () => { + it('returns valid for YouTube URL', () => { + const result = validateVideoUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ') + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + }) + + it('returns valid for Vimeo URL', () => { + const result = validateVideoUrl('https://vimeo.com/123456789') + + expect(result.valid).toBe(true) + }) + + it('returns valid for direct video URL', () => { + const result = validateVideoUrl('https://example.com/video.mp4') + + expect(result.valid).toBe(true) + }) + + it('returns invalid for empty URL', () => { + const result = validateVideoUrl('') + + expect(result.valid).toBe(false) + expect(result.error).toBe('URL ist erforderlich') + }) + + it('returns invalid for URL without protocol', () => { + const result = validateVideoUrl('youtube.com/watch?v=dQw4w9WgXcQ') + + expect(result.valid).toBe(false) + expect(result.error).toContain('http') + }) + + it('returns invalid for unknown URL format', () => { + const result = validateVideoUrl('https://example.com/page') + + expect(result.valid).toBe(false) + expect(result.error).toContain('Unbekanntes Video-Format') + }) + }) +})