mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
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:
parent
58b48555d7
commit
913897c87c
16 changed files with 3548 additions and 263 deletions
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
92
src/collections/VideoCategories.ts
Normal file
92
src/collections/VideoCategories.ts
Normal 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
413
src/collections/Videos.ts
Normal 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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
88
src/hooks/processFeaturedVideo.ts
Normal file
88
src/hooks/processFeaturedVideo.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
12
src/lib/validation/index.ts
Normal file
12
src/lib/validation/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Validation Module
|
||||
*
|
||||
* Exportiert alle Validierungs-Funktionen.
|
||||
*/
|
||||
|
||||
export {
|
||||
validateUniqueSlug,
|
||||
createSlugValidationHook,
|
||||
generateUniqueSlug,
|
||||
type SlugValidationOptions,
|
||||
} from './slug-validation'
|
||||
156
src/lib/validation/slug-validation.ts
Normal file
156
src/lib/validation/slug-validation.ts
Normal 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
21
src/lib/video/index.ts
Normal 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'
|
||||
352
src/lib/video/video-utils.ts
Normal file
352
src/lib/video/video-utils.ts
Normal 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 }
|
||||
}
|
||||
470
src/migrations/20251216_073000_add_video_collections.ts
Normal file
470
src/migrations/20251216_073000_add_video_collections.ts
Normal 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";
|
||||
|
||||
`);
|
||||
}
|
||||
|
|
@ -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";
|
||||
`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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: {},
|
||||
|
|
|
|||
298
tests/int/videos.int.spec.ts
Normal file
298
tests/int/videos.int.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
532
tests/unit/video/video-utils.unit.spec.ts
Normal file
532
tests/unit/video/video-utils.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue