mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +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'
|
import type { Block } from 'payload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VideoBlock
|
||||||
|
*
|
||||||
|
* Erweiterter Video-Block mit Unterstützung für:
|
||||||
|
* - YouTube/Vimeo Embeds
|
||||||
|
* - Video-Uploads
|
||||||
|
* - Video-Bibliothek (Videos Collection)
|
||||||
|
* - Externe Video-URLs
|
||||||
|
*/
|
||||||
export const VideoBlock: Block = {
|
export const VideoBlock: Block = {
|
||||||
slug: 'video-block',
|
slug: 'video-block',
|
||||||
labels: {
|
labels: {
|
||||||
|
|
@ -7,13 +16,68 @@ export const VideoBlock: Block = {
|
||||||
plural: 'Videos',
|
plural: 'Videos',
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
|
// === QUELLE ===
|
||||||
|
{
|
||||||
|
name: 'sourceType',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'embed',
|
||||||
|
label: 'Video-Quelle',
|
||||||
|
options: [
|
||||||
|
{ label: 'YouTube/Vimeo URL', value: 'embed' },
|
||||||
|
{ label: 'Video hochladen', value: 'upload' },
|
||||||
|
{ label: 'Aus Video-Bibliothek', value: 'library' },
|
||||||
|
{ label: 'Externe URL', value: 'external' },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: 'Woher soll das Video eingebunden werden?',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Video aus Bibliothek
|
||||||
|
{
|
||||||
|
name: 'videoFromLibrary',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'videos',
|
||||||
|
label: 'Video auswählen',
|
||||||
|
admin: {
|
||||||
|
description: 'Video aus der Video-Bibliothek auswählen',
|
||||||
|
condition: (_, siblingData) => siblingData?.sourceType === 'library',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// YouTube/Vimeo oder externe URL
|
||||||
{
|
{
|
||||||
name: 'videoUrl',
|
name: 'videoUrl',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: true,
|
|
||||||
label: 'Video-URL',
|
label: 'Video-URL',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'YouTube oder Vimeo URL',
|
description: 'YouTube, Vimeo oder externe Video-URL',
|
||||||
|
condition: (_, siblingData) =>
|
||||||
|
siblingData?.sourceType === 'embed' || siblingData?.sourceType === 'external',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hochgeladenes Video
|
||||||
|
{
|
||||||
|
name: 'videoFile',
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
label: 'Video-Datei',
|
||||||
|
admin: {
|
||||||
|
description: 'MP4, WebM oder andere Video-Dateien hochladen',
|
||||||
|
condition: (_, siblingData) => siblingData?.sourceType === 'upload',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === DARSTELLUNG ===
|
||||||
|
{
|
||||||
|
name: 'thumbnail',
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
label: 'Vorschaubild',
|
||||||
|
admin: {
|
||||||
|
description: 'Eigenes Thumbnail (optional, bei YouTube wird automatisch eines verwendet)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -21,6 +85,9 @@ export const VideoBlock: Block = {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
label: 'Beschriftung',
|
label: 'Beschriftung',
|
||||||
localized: true,
|
localized: true,
|
||||||
|
admin: {
|
||||||
|
description: 'Bildunterschrift unter dem Video',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'aspectRatio',
|
name: 'aspectRatio',
|
||||||
|
|
@ -28,9 +95,174 @@ export const VideoBlock: Block = {
|
||||||
defaultValue: '16:9',
|
defaultValue: '16:9',
|
||||||
label: 'Seitenverhältnis',
|
label: 'Seitenverhältnis',
|
||||||
options: [
|
options: [
|
||||||
{ label: '16:9', value: '16:9' },
|
{ label: '16:9 (Standard)', value: '16:9' },
|
||||||
{ label: '4:3', value: '4:3' },
|
{ label: '4:3', value: '4:3' },
|
||||||
{ label: '1:1', value: '1:1' },
|
{ label: '1:1 (Quadrat)', value: '1:1' },
|
||||||
|
{ label: '9:16 (Vertikal)', value: '9:16' },
|
||||||
|
{ label: '21:9 (Ultrawide)', value: '21:9' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'size',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'full',
|
||||||
|
label: 'Größe',
|
||||||
|
options: [
|
||||||
|
{ label: 'Volle Breite', value: 'full' },
|
||||||
|
{ label: 'Groß (75%)', value: 'large' },
|
||||||
|
{ label: 'Mittel (50%)', value: 'medium' },
|
||||||
|
{ label: 'Klein (33%)', value: 'small' },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: 'Breite des Video-Containers',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'alignment',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'center',
|
||||||
|
label: 'Ausrichtung',
|
||||||
|
options: [
|
||||||
|
{ label: 'Links', value: 'left' },
|
||||||
|
{ label: 'Zentriert', value: 'center' },
|
||||||
|
{ label: 'Rechts', value: 'right' },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
condition: (_, siblingData) => siblingData?.size !== 'full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === WIEDERGABE-OPTIONEN ===
|
||||||
|
{
|
||||||
|
name: 'playback',
|
||||||
|
type: 'group',
|
||||||
|
label: 'Wiedergabe',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'autoplay',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'Autoplay',
|
||||||
|
admin: {
|
||||||
|
description: 'Video automatisch starten (erfordert meist Mute)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'muted',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'Stummgeschaltet',
|
||||||
|
admin: {
|
||||||
|
description: 'Video stumm abspielen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'loop',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'Wiederholen',
|
||||||
|
admin: {
|
||||||
|
description: 'Video in Endlosschleife abspielen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'controls',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
label: 'Steuerung anzeigen',
|
||||||
|
admin: {
|
||||||
|
description: 'Video-Controls anzeigen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'playsinline',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
label: 'Inline abspielen',
|
||||||
|
admin: {
|
||||||
|
description: 'Auf Mobile inline statt Vollbild abspielen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'startTime',
|
||||||
|
type: 'number',
|
||||||
|
min: 0,
|
||||||
|
label: 'Startzeit (Sekunden)',
|
||||||
|
admin: {
|
||||||
|
description: 'Video ab dieser Sekunde starten',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// === EMBED-OPTIONEN (nur für YouTube/Vimeo) ===
|
||||||
|
{
|
||||||
|
name: 'embedOptions',
|
||||||
|
type: 'group',
|
||||||
|
label: 'Embed-Optionen',
|
||||||
|
admin: {
|
||||||
|
condition: (_, siblingData) => siblingData?.sourceType === 'embed',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'showRelated',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'Ähnliche Videos anzeigen',
|
||||||
|
admin: {
|
||||||
|
description: 'Am Ende ähnliche Videos von YouTube/Vimeo anzeigen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'privacyMode',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
label: 'Datenschutz-Modus',
|
||||||
|
admin: {
|
||||||
|
description: 'YouTube-nocookie.com verwenden (DSGVO-konformer)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// === STYLING ===
|
||||||
|
{
|
||||||
|
name: 'style',
|
||||||
|
type: 'group',
|
||||||
|
label: 'Styling',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'rounded',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'none',
|
||||||
|
label: 'Ecken abrunden',
|
||||||
|
options: [
|
||||||
|
{ label: 'Keine', value: 'none' },
|
||||||
|
{ label: 'Leicht (sm)', value: 'sm' },
|
||||||
|
{ label: 'Mittel (md)', value: 'md' },
|
||||||
|
{ label: 'Stark (lg)', value: 'lg' },
|
||||||
|
{ label: 'Extra (xl)', value: 'xl' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shadow',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'none',
|
||||||
|
label: 'Schatten',
|
||||||
|
options: [
|
||||||
|
{ label: 'Kein', value: 'none' },
|
||||||
|
{ label: 'Leicht', value: 'sm' },
|
||||||
|
{ label: 'Mittel', value: 'md' },
|
||||||
|
{ label: 'Stark', value: 'lg' },
|
||||||
|
{ label: 'Extra', value: 'xl' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'border',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'Rahmen anzeigen',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess'
|
import { authenticatedOnly, tenantScopedPublicRead } from '../lib/tenantAccess'
|
||||||
|
import { processFeaturedVideo } from '../hooks/processFeaturedVideo'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Berechnet die geschätzte Lesezeit basierend auf Wortanzahl
|
* Berechnet die geschätzte Lesezeit basierend auf Wortanzahl
|
||||||
|
|
@ -105,6 +106,143 @@ export const Posts: CollectionConfig = {
|
||||||
relationTo: 'media',
|
relationTo: 'media',
|
||||||
label: 'Beitragsbild',
|
label: 'Beitragsbild',
|
||||||
},
|
},
|
||||||
|
// === FEATURED VIDEO ===
|
||||||
|
{
|
||||||
|
name: 'featuredVideo',
|
||||||
|
type: 'group',
|
||||||
|
label: 'Featured Video',
|
||||||
|
admin: {
|
||||||
|
description: 'Optional: Video als Hero-Element für diesen Beitrag',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'enabled',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'Featured Video aktivieren',
|
||||||
|
admin: {
|
||||||
|
description: 'Video als primäres Medienelement verwenden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'replaceImage',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'Beitragsbild ersetzen',
|
||||||
|
admin: {
|
||||||
|
description: 'Video statt Beitragsbild im Hero-Bereich anzeigen',
|
||||||
|
condition: (_, siblingData) => siblingData?.enabled === true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'source',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: 'library',
|
||||||
|
label: 'Video-Quelle',
|
||||||
|
options: [
|
||||||
|
{ label: 'Aus Video-Bibliothek', value: 'library' },
|
||||||
|
{ label: 'YouTube/Vimeo URL', value: 'embed' },
|
||||||
|
{ label: 'Video hochladen', value: 'upload' },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
condition: (_, siblingData) => siblingData?.enabled === true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'video',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'videos',
|
||||||
|
label: 'Video auswählen',
|
||||||
|
admin: {
|
||||||
|
description: 'Video aus der Video-Bibliothek auswählen',
|
||||||
|
condition: (_, siblingData) =>
|
||||||
|
siblingData?.enabled === true && siblingData?.source === 'library',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'embedUrl',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Video-URL',
|
||||||
|
admin: {
|
||||||
|
description: 'YouTube oder Vimeo URL',
|
||||||
|
condition: (_, siblingData) =>
|
||||||
|
siblingData?.enabled === true && siblingData?.source === 'embed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'uploadedVideo',
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
label: 'Video-Datei',
|
||||||
|
admin: {
|
||||||
|
description: 'MP4, WebM oder andere Video-Dateien',
|
||||||
|
condition: (_, siblingData) =>
|
||||||
|
siblingData?.enabled === true && siblingData?.source === 'upload',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'autoplay',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: 'Autoplay',
|
||||||
|
admin: {
|
||||||
|
description: 'Video automatisch starten (erfordert Mute)',
|
||||||
|
condition: (_, siblingData) => siblingData?.enabled === true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'muted',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
label: 'Stummgeschaltet',
|
||||||
|
admin: {
|
||||||
|
description: 'Video stumm abspielen (empfohlen für Autoplay)',
|
||||||
|
condition: (_, siblingData) => siblingData?.enabled === true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Processed fields (populated by hook)
|
||||||
|
{
|
||||||
|
name: 'processedEmbedUrl',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Automatisch generierte Embed-URL mit Privacy-Mode',
|
||||||
|
condition: (_, siblingData) =>
|
||||||
|
siblingData?.enabled === true && siblingData?.source === 'embed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'extractedVideoId',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Extrahierte Video-ID (z.B. YouTube Video-ID)',
|
||||||
|
condition: (_, siblingData) =>
|
||||||
|
siblingData?.enabled === true && siblingData?.source === 'embed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'platform',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Erkannte Plattform (youtube, vimeo, etc.)',
|
||||||
|
condition: (_, siblingData) =>
|
||||||
|
siblingData?.enabled === true && siblingData?.source === 'embed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'thumbnailUrl',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Auto-generierte Thumbnail-URL',
|
||||||
|
condition: (_, siblingData) =>
|
||||||
|
siblingData?.enabled === true && siblingData?.source === 'embed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'content',
|
name: 'content',
|
||||||
type: 'richText',
|
type: 'richText',
|
||||||
|
|
@ -219,6 +357,7 @@ export const Posts: CollectionConfig = {
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeChange: [
|
beforeChange: [
|
||||||
|
processFeaturedVideo,
|
||||||
({ data }) => {
|
({ data }) => {
|
||||||
// Automatische Lesezeit-Berechnung
|
// Automatische Lesezeit-Berechnung
|
||||||
if (data?.content) {
|
if (data?.content) {
|
||||||
|
|
|
||||||
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_20251213_230000_team_extensions from './20251213_230000_team_extensions';
|
||||||
import * as migration_20251214_000000_add_priority_collections from './20251214_000000_add_priority_collections';
|
import * as migration_20251214_000000_add_priority_collections from './20251214_000000_add_priority_collections';
|
||||||
import * as migration_20251214_010000_tenant_specific_collections from './20251214_010000_tenant_specific_collections';
|
import * as migration_20251214_010000_tenant_specific_collections from './20251214_010000_tenant_specific_collections';
|
||||||
|
import * as migration_20251216_073000_add_video_collections from './20251216_073000_add_video_collections';
|
||||||
|
import * as migration_20251216_080000_posts_featured_video_processed_fields from './20251216_080000_posts_featured_video_processed_fields';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -120,4 +122,14 @@ export const migrations = [
|
||||||
down: migration_20251214_010000_tenant_specific_collections.down,
|
down: migration_20251214_010000_tenant_specific_collections.down,
|
||||||
name: '20251214_010000_tenant_specific_collections',
|
name: '20251214_010000_tenant_specific_collections',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20251216_073000_add_video_collections.up,
|
||||||
|
down: migration_20251216_073000_add_video_collections.down,
|
||||||
|
name: '20251216_073000_add_video_collections',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20251216_080000_posts_featured_video_processed_fields.up,
|
||||||
|
down: migration_20251216_080000_posts_featured_video_processed_fields.down,
|
||||||
|
name: '20251216_080000_posts_featured_video_processed_fields',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -34,6 +34,10 @@ import { NewsletterSubscribers } from './collections/NewsletterSubscribers'
|
||||||
import { PortfolioCategories } from './collections/PortfolioCategories'
|
import { PortfolioCategories } from './collections/PortfolioCategories'
|
||||||
import { Portfolios } from './collections/Portfolios'
|
import { Portfolios } from './collections/Portfolios'
|
||||||
|
|
||||||
|
// Video Collections
|
||||||
|
import { VideoCategories } from './collections/VideoCategories'
|
||||||
|
import { Videos } from './collections/Videos'
|
||||||
|
|
||||||
// Product Collections
|
// Product Collections
|
||||||
import { ProductCategories } from './collections/ProductCategories'
|
import { ProductCategories } from './collections/ProductCategories'
|
||||||
import { Products } from './collections/Products'
|
import { Products } from './collections/Products'
|
||||||
|
|
@ -171,6 +175,9 @@ export default buildConfig({
|
||||||
// Portfolio
|
// Portfolio
|
||||||
PortfolioCategories,
|
PortfolioCategories,
|
||||||
Portfolios,
|
Portfolios,
|
||||||
|
// Videos
|
||||||
|
VideoCategories,
|
||||||
|
Videos,
|
||||||
// Products
|
// Products
|
||||||
ProductCategories,
|
ProductCategories,
|
||||||
Products,
|
Products,
|
||||||
|
|
@ -234,6 +241,9 @@ export default buildConfig({
|
||||||
// Portfolio Collections
|
// Portfolio Collections
|
||||||
'portfolio-categories': {},
|
'portfolio-categories': {},
|
||||||
portfolios: {},
|
portfolios: {},
|
||||||
|
// Video Collections
|
||||||
|
'video-categories': {},
|
||||||
|
videos: {},
|
||||||
// Product Collections
|
// Product Collections
|
||||||
'product-categories': {},
|
'product-categories': {},
|
||||||
products: {},
|
products: {},
|
||||||
|
|
|
||||||
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