feat: add comprehensive video feature with collections, hooks, and tests

Video Feature Implementation:
- Add Videos and VideoCategories collections with multi-tenant support
- Extend VideoBlock with library/upload/embed sources and playback options
- Add featuredVideo group to Posts collection with processed embed URLs

Hooks & Validation:
- Add processFeaturedVideo hook for URL parsing and privacy mode embedding
- Add createSlugValidationHook for tenant-scoped slug uniqueness
- Add video-utils library (parseVideoUrl, generateEmbedUrl, formatDuration)

Testing:
- Add 84 unit tests for video-utils (URL parsing, duration, embed generation)
- Add 14 integration tests for Videos collection CRUD and slug validation

Database:
- Migration for videos, video_categories tables with locales
- Migration for Posts featuredVideo processed fields
- Update payload internal tables for new collections

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-16 10:48:33 +00:00
parent 58b48555d7
commit 913897c87c
16 changed files with 3548 additions and 263 deletions

View file

@ -1,5 +1,14 @@
import type { Block } from 'payload' 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 = { export const VideoBlock: Block = {
slug: 'video-block', slug: 'video-block',
labels: { labels: {
@ -7,13 +16,68 @@ export const VideoBlock: Block = {
plural: 'Videos', plural: 'Videos',
}, },
fields: [ 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', name: 'videoUrl',
type: 'text', type: 'text',
required: true,
label: 'Video-URL', label: 'Video-URL',
admin: { 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', type: 'text',
label: 'Beschriftung', label: 'Beschriftung',
localized: true, localized: true,
admin: {
description: 'Bildunterschrift unter dem Video',
},
}, },
{ {
name: 'aspectRatio', name: 'aspectRatio',
@ -28,9 +95,174 @@ export const VideoBlock: Block = {
defaultValue: '16:9', defaultValue: '16:9',
label: 'Seitenverhältnis', label: 'Seitenverhältnis',
options: [ options: [
{ label: '16:9', value: '16:9' }, { label: '16:9 (Standard)', value: '16:9' },
{ label: '4:3', value: '4:3' }, { 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',
},
], ],
}, },
], ],

View file

@ -1,5 +1,6 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess' import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess'
import { processFeaturedVideo } from '../hooks/processFeaturedVideo'
/** /**
* Berechnet die geschätzte Lesezeit basierend auf Wortanzahl * Berechnet die geschätzte Lesezeit basierend auf Wortanzahl
@ -105,6 +106,143 @@ export const Posts: CollectionConfig = {
relationTo: 'media', relationTo: 'media',
label: 'Beitragsbild', 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', name: 'content',
type: 'richText', type: 'richText',
@ -219,6 +357,7 @@ export const Posts: CollectionConfig = {
], ],
hooks: { hooks: {
beforeChange: [ beforeChange: [
processFeaturedVideo,
({ data }) => { ({ data }) => {
// Automatische Lesezeit-Berechnung // Automatische Lesezeit-Berechnung
if (data?.content) { if (data?.content) {

View file

@ -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' }),
],
},
}

413
src/collections/Videos.ts Normal file
View file

@ -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<string, string> = { ä: '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
},
],
},
}

View file

@ -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<PostData> = 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,
}
}

View file

@ -0,0 +1,12 @@
/**
* Validation Module
*
* Exportiert alle Validierungs-Funktionen.
*/
export {
validateUniqueSlug,
createSlugValidationHook,
generateUniqueSlug,
type SlugValidationOptions,
} from './slug-validation'

View file

@ -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<string, unknown>,
options: SlugValidationOptions & {
existingId?: number | string
locale?: string
}
): Promise<void> {
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<string, unknown> = {
[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<string, unknown>
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<string> {
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<string, unknown> = {
[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
}

21
src/lib/video/index.ts Normal file
View file

@ -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'

View file

@ -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<string, string> = {
'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<string, string> = {
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 }
}

View file

@ -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<void> {
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<void> {
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";
`);
}

View file

@ -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<void> {
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<void> {
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";
`)
}

View file

@ -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_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_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_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 = [ export const migrations = [
{ {
@ -120,4 +122,14 @@ export const migrations = [
down: migration_20251214_010000_tenant_specific_collections.down, down: migration_20251214_010000_tenant_specific_collections.down,
name: '20251214_010000_tenant_specific_collections', 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',
},
]; ];

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,10 @@ import { NewsletterSubscribers } from './collections/NewsletterSubscribers'
import { PortfolioCategories } from './collections/PortfolioCategories' import { PortfolioCategories } from './collections/PortfolioCategories'
import { Portfolios } from './collections/Portfolios' import { Portfolios } from './collections/Portfolios'
// Video Collections
import { VideoCategories } from './collections/VideoCategories'
import { Videos } from './collections/Videos'
// Product Collections // Product Collections
import { ProductCategories } from './collections/ProductCategories' import { ProductCategories } from './collections/ProductCategories'
import { Products } from './collections/Products' import { Products } from './collections/Products'
@ -171,6 +175,9 @@ export default buildConfig({
// Portfolio // Portfolio
PortfolioCategories, PortfolioCategories,
Portfolios, Portfolios,
// Videos
VideoCategories,
Videos,
// Products // Products
ProductCategories, ProductCategories,
Products, Products,
@ -234,6 +241,9 @@ export default buildConfig({
// Portfolio Collections // Portfolio Collections
'portfolio-categories': {}, 'portfolio-categories': {},
portfolios: {}, portfolios: {},
// Video Collections
'video-categories': {},
videos: {},
// Product Collections // Product Collections
'product-categories': {}, 'product-categories': {},
products: {}, products: {},

View file

@ -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
})
})
})

View file

@ -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')
})
})
})