video func implemented

This commit is contained in:
echo 2026-02-03 21:18:25 +01:00
parent 3bc534489d
commit 89b8687431
13 changed files with 504 additions and 9 deletions

View File

@ -8,7 +8,7 @@ import {
IsBoolean, IsBoolean,
IsDate, IsDate,
} from 'class-validator'; } from 'class-validator';
import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize } from './entities'; import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize, VideoPosition } from './entities';
export class CreateArticleDto { export class CreateArticleDto {
@IsString() @IsString()
@ -57,6 +57,18 @@ export class CreateArticleDto {
@IsOptional() @IsOptional()
@IsEnum(ImageSize) @IsEnum(ImageSize)
imageSize?: ImageSize; imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class UpdateArticleDto { export class UpdateArticleDto {
@ -108,6 +120,18 @@ export class UpdateArticleDto {
@IsOptional() @IsOptional()
@IsEnum(ImageSize) @IsEnum(ImageSize)
imageSize?: ImageSize; imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class FindArticlesDto { export class FindArticlesDto {
@ -181,6 +205,18 @@ export class CreateLiveBlogDto {
@IsOptional() @IsOptional()
@IsEnum(ImageSize) @IsEnum(ImageSize)
imageSize?: ImageSize; imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class UpdateLiveBlogDto { export class UpdateLiveBlogDto {
@ -227,6 +263,18 @@ export class UpdateLiveBlogDto {
@IsOptional() @IsOptional()
@IsEnum(ImageSize) @IsEnum(ImageSize)
imageSize?: ImageSize; imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class CreateLiveBlogUpdateDto { export class CreateLiveBlogUpdateDto {

View File

@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Article, ArticleStatus } from './entities'; import { Article, ArticleStatus } from './entities';
@ -10,6 +10,8 @@ import {
@Injectable() @Injectable()
export class ArticlesService { export class ArticlesService {
private readonly logger = new Logger(ArticlesService.name);
constructor( constructor(
@InjectRepository(Article) @InjectRepository(Article)
private readonly articleRepository: Repository<Article>, private readonly articleRepository: Repository<Article>,
@ -125,4 +127,16 @@ export class ArticlesService {
return await this.articleRepository.save(article); return await this.articleRepository.save(article);
} }
async removeByStrapiId(strapiId: string): Promise<void> {
const article = await this.articleRepository.findOne({ where: { strapiId } });
if (!article) {
this.logger.warn(`Article with strapiId ${strapiId} not found`);
return;
}
await this.articleRepository.remove(article);
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
}
} }

View File

@ -49,6 +49,13 @@ export enum ImageSize {
LARGE = 'large', LARGE = 'large',
} }
export enum VideoPosition {
TOP = 'top',
INLINE = 'inline',
BOTTOM = 'bottom',
NONE = 'none',
}
@Entity('authors') @Entity('authors')
export class Author { export class Author {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -139,6 +146,18 @@ export class Article {
}) })
imageSize: ImageSize; imageSize: ImageSize;
@Column({ default: '' })
videoUrl: string;
@Column({
type: 'text',
default: 'inline',
})
videoPosition: VideoPosition;
@Column({ default: '' })
videoCaption: string;
@Column({ @Column({
type: 'text', type: 'text',
default: '[]', default: '[]',
@ -226,6 +245,18 @@ export class LiveBlog {
}) })
imageSize: ImageSize; imageSize: ImageSize;
@Column({ default: '' })
videoUrl: string;
@Column({
type: 'text',
default: 'inline',
})
videoPosition: VideoPosition;
@Column({ default: '' })
videoCaption: string;
@Column({ default: 0 }) @Column({ default: 0 })
viewCount: number; viewCount: number;

View File

@ -442,6 +442,18 @@ export class LiveBlogService implements OnModuleInit {
return await this.liveBlogRepository.save(liveBlog); return await this.liveBlogRepository.save(liveBlog);
} }
async removeByStrapiId(strapiId: string): Promise<void> {
const liveBlog = await this.liveBlogRepository.findOne({ where: { strapiId } });
if (!liveBlog) {
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
return;
}
await this.liveBlogRepository.remove(liveBlog);
this.logger.log(`Successfully deleted live blog with strapiId: ${strapiId}`);
}
// Utility methods // Utility methods
async getLiveBlogsWithRecentUpdates(hours = 24): Promise<LiveBlog[]> { async getLiveBlogsWithRecentUpdates(hours = 24): Promise<LiveBlog[]> {
const since = new Date(); const since = new Date();

View File

@ -5,7 +5,7 @@ import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { LiveBlogService } from './live-blog.service'; import { LiveBlogService } from './live-blog.service';
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto'; import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize } from './entities'; import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize, VideoPosition } from './entities';
interface StrapiArticle { interface StrapiArticle {
id: number; id: number;
@ -21,6 +21,9 @@ interface StrapiArticle {
media?: any[]; media?: any[];
imagePosition?: string; imagePosition?: string;
imageSize?: string; imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
} }
interface StrapiLiveBlog { interface StrapiLiveBlog {
@ -37,6 +40,9 @@ interface StrapiLiveBlog {
media?: any[]; media?: any[];
imagePosition?: string; imagePosition?: string;
imageSize?: string; imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
} }
interface StrapiResponse<T> { interface StrapiResponse<T> {
@ -162,6 +168,9 @@ export class StrapiService {
featuredImage: imageUrl, featuredImage: imageUrl,
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition, imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize, imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
videoUrl: strapiArticle.videoUrl || '',
videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '',
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@ -223,6 +232,9 @@ export class StrapiService {
featuredImage: imageUrl, featuredImage: imageUrl,
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition, imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize, imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
videoUrl: strapiArticle.videoUrl || '',
videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition,
videoCaption: strapiArticle.videoCaption || '',
}; };
await this.articlesService.syncFromStrapi( await this.articlesService.syncFromStrapi(
@ -287,6 +299,9 @@ export class StrapiService {
featuredImage: imageUrl, featuredImage: imageUrl,
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition, imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize, imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
videoUrl: strapiLiveBlog.videoUrl || '',
videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '',
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@ -342,6 +357,9 @@ export class StrapiService {
featuredImage: imageUrl, featuredImage: imageUrl,
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition, imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize, imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
videoUrl: strapiLiveBlog.videoUrl || '',
videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition,
videoCaption: strapiLiveBlog.videoCaption || '',
}; };
await this.liveBlogService.syncFromStrapi( await this.liveBlogService.syncFromStrapi(
@ -403,7 +421,15 @@ export class StrapiService {
); );
if (event === 'entry.delete') { if (event === 'entry.delete') {
this.logger.log(`Handling delete for document: ${data.documentId}`); this.logger.log(`Handling delete for document: ${data.documentId}, model: ${data.model}`);
if (data.model === 'article') {
await this.articlesService.removeByStrapiId(data.documentId);
} else if (data.model === 'live-blog') {
await this.liveBlogService.removeByStrapiId(data.documentId);
} else {
this.logger.warn(`Cannot delete: unknown model type: ${data.model}`);
}
return; return;
} }

View File

@ -49,6 +49,20 @@
"type": "enumeration", "type": "enumeration",
"enum": ["small", "medium", "large"], "enum": ["small", "medium", "large"],
"default": "medium" "default": "medium"
},
"videoUrl": {
"type": "string",
"regex": "^(https?:\\/\\/)?(www\\.)?(youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)[a-zA-Z0-9_-]{11}",
"default": ""
},
"videoPosition": {
"type": "enumeration",
"enum": ["top", "inline", "bottom", "none"],
"default": "inline"
},
"videoCaption": {
"type": "string",
"default": ""
} }
} }
} }

View File

@ -446,6 +446,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
createdAt: Schema.Attribute.DateTime; createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
imagePosition: Schema.Attribute.Enumeration<
['top', 'left', 'right', 'none']
> &
Schema.Attribute.DefaultTo<'top'>;
imageSize: Schema.Attribute.Enumeration<['small', 'medium', 'large']> &
Schema.Attribute.DefaultTo<'medium'>;
img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>; img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
locale: Schema.Attribute.String & Schema.Attribute.Private; locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation< localizations: Schema.Attribute.Relation<
@ -462,6 +468,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
updatedAt: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
videoCaption: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
videoPosition: Schema.Attribute.Enumeration<
['top', 'inline', 'bottom', 'none']
> &
Schema.Attribute.DefaultTo<'inline'>;
videoUrl: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
}; };
} }

View File

@ -3,6 +3,8 @@ import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api' import * as api from '@/lib/api'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { YouTubeEmbed } from '@/components/ui/youtube-embed'
import { extractYouTubeVideoId, getVideoPositionClasses } from '@/lib/video-utils'
export function ArticleDetailComponent({ id }: { id: string }) { export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@ -110,6 +112,21 @@ export function ArticleDetailComponent({ id }: { id: string }) {
</div> </div>
)} )}
{/* Video rendering */}
{data.videoUrl && data.videoPosition !== 'none' && (
<div className={getVideoPositionClasses(data.videoPosition)}>
<YouTubeEmbed
url={data.videoUrl}
title={data.title}
caption={data.videoCaption}
autoplay={false}
controls={true}
modestbranding={true}
showRelated={false}
/>
</div>
)}
<div className="prose prose-slate max-w-none"> <div className="prose prose-slate max-w-none">
<div className="text-lg leading-relaxed mb-6"> <div className="text-lg leading-relaxed mb-6">
<ReactMarkdown <ReactMarkdown
@ -121,7 +138,27 @@ export function ArticleDetailComponent({ id }: { id: string }) {
className="max-w-full h-auto rounded-lg my-4" className="max-w-full h-auto rounded-lg my-4"
alt={props.alt || 'Article image'} alt={props.alt || 'Article image'}
/> />
) ),
a: (props) => {
// Check if the link is a YouTube URL
const videoId = extractYouTubeVideoId(props.href || '');
if (videoId) {
return (
<div className="my-6">
<YouTubeEmbed
url={props.href || ''}
title={props.title || 'YouTube video'}
autoplay={false}
controls={true}
modestbranding={true}
showRelated={false}
/>
</div>
);
}
// Regular link
return <a {...props} className="text-blue-600 hover:text-blue-800 underline" />;
}
}} }}
> >
{data.content} {data.content}

View File

@ -0,0 +1,134 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { extractYouTubeVideoId, generateYouTubeEmbedUrl } from '@/lib/video-utils';
interface YouTubeEmbedProps {
/** YouTube video URL or video ID */
url: string;
/** Video title for accessibility */
title?: string;
/** Optional caption below the video */
caption?: string;
/** Additional CSS classes */
className?: string;
/** Autoplay the video (muted on mobile) */
autoplay?: boolean;
/** Show video controls */
controls?: boolean;
/** Reduce YouTube branding */
modestbranding?: boolean;
/** Show related videos at the end */
showRelated?: boolean;
/** Start time in seconds */
startTime?: number;
/** End time in seconds */
endTime?: number;
/** Show loading state */
showLoading?: boolean;
}
export function YouTubeEmbed({
url,
title = 'YouTube video',
caption,
className,
autoplay = false,
controls = true,
modestbranding = true,
showRelated = false,
startTime,
endTime,
showLoading = true,
}: YouTubeEmbedProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
// Extract video ID from URL
const videoId = extractYouTubeVideoId(url);
if (!videoId) {
return (
<div className={cn('p-4 bg-destructive/10 border border-destructive/20 rounded-lg', className)}>
<p className="text-destructive text-sm">
Invalid YouTube URL: {url}
</p>
</div>
);
}
// Generate embed URL with options
const embedUrl = generateYouTubeEmbedUrl(videoId, {
autoplay,
controls,
modestbranding,
rel: showRelated,
playsinline: true,
start: startTime,
end: endTime,
});
return (
<div className={cn('w-full', className)}>
{/* Video container with aspect ratio */}
<div className="relative pt-[56.25%] bg-muted rounded-lg overflow-hidden">
{/* Loading state */}
{showLoading && !isLoaded && !hasError && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
)}
{/* Error state */}
{hasError && (
<div className="absolute inset-0 flex flex-col items-center justify-center p-4 text-center">
<div className="text-destructive mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-destructive font-medium">Video failed to load</p>
<p className="text-muted-foreground text-sm mt-1">
The YouTube video could not be loaded. Please check the URL or try again later.
</p>
</div>
)}
{/* YouTube iframe */}
{!hasError && (
<iframe
className={cn(
'absolute top-0 left-0 w-full h-full border-0 rounded-lg',
!isLoaded && 'opacity-0'
)}
src={embedUrl}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
/>
)}
</div>
{/* Caption */}
{caption && (
<div className="mt-2 text-sm text-muted-foreground text-center">
{caption}
</div>
)}
</div>
);
}

View File

@ -124,14 +124,14 @@ export function useLiveBlogStream(
setConnectionError('Connection to live blog lost'); setConnectionError('Connection to live blog lost');
// Attempt reconnection if enabled and within limits // Attempt reconnection if enabled and within limits
if (optionsRef.current.autoReconnect && reconnectAttemptsRef.current < optionsRef.current.maxReconnectAttempts) { if (optionsRef.current.autoReconnect && reconnectAttemptsRef.current < (optionsRef.current.maxReconnectAttempts || 10)) {
const nextAttempt = reconnectAttemptsRef.current + 1; const nextAttempt = reconnectAttemptsRef.current + 1;
setReconnectAttempts(nextAttempt); setReconnectAttempts(nextAttempt);
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts})`); console.log(`Attempting reconnection (${nextAttempt}/${optionsRef.current.maxReconnectAttempts || 10})`);
createConnection(); createConnection();
}, optionsRef.current.reconnectInterval); }, optionsRef.current.reconnectInterval || 3000);
} else if (reconnectAttemptsRef.current >= optionsRef.current.maxReconnectAttempts) { } else if (reconnectAttemptsRef.current >= (optionsRef.current.maxReconnectAttempts || 10)) {
setConnectionError('Failed to reconnect after multiple attempts'); setConnectionError('Failed to reconnect after multiple attempts');
} }
}; };

View File

@ -13,6 +13,9 @@ export interface Article {
featuredImage: string; featuredImage: string;
imagePosition: 'top' | 'left' | 'right' | 'none'; imagePosition: 'top' | 'left' | 'right' | 'none';
imageSize: 'small' | 'medium' | 'large'; imageSize: 'small' | 'medium' | 'large';
videoUrl: string;
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
videoCaption: string;
tags: string[]; tags: string[];
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
views: number; views: number;
@ -131,6 +134,9 @@ export interface LiveBlog {
featuredImage: string; featuredImage: string;
imagePosition: 'top' | 'left' | 'right' | 'none'; imagePosition: 'top' | 'left' | 'right' | 'none';
imageSize: 'small' | 'medium' | 'large'; imageSize: 'small' | 'medium' | 'large';
videoUrl: string;
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
videoCaption: string;
viewCount: number; viewCount: number;
author?: { author?: {
id: string; id: string;

View File

@ -0,0 +1,157 @@
/**
* Video utilities for handling YouTube embeds and video URLs
*/
export type VideoPosition = 'top' | 'inline' | 'bottom' | 'none';
export type VideoPlatform = 'youtube' | 'vimeo' | 'dailymotion' | 'twitch' | 'uploaded' | 'external';
/**
* Extract YouTube video ID from various URL formats
* Supported formats:
* - https://www.youtube.com/watch?v=VIDEO_ID
* - https://youtu.be/VIDEO_ID
* - https://www.youtube.com/embed/VIDEO_ID
* - https://www.youtube.com/v/VIDEO_ID
* - https://www.youtube.com/shorts/VIDEO_ID
*/
export function extractYouTubeVideoId(url: string): string | null {
if (!url) return null;
const patterns = [
// Standard watch URL
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
// Just the video ID
/^([a-zA-Z0-9_-]{11})$/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
}
/**
* Check if a URL is a valid YouTube URL
*/
export function isValidYouTubeUrl(url: string): boolean {
return extractYouTubeVideoId(url) !== null;
}
/**
* Get YouTube thumbnail URL for a video ID
* Quality options: default, mq (medium), hq (high), sd (standard), maxres (maximum resolution)
*/
export function getYouTubeThumbnail(
videoId: string,
quality: 'default' | 'mq' | 'hq' | 'sd' | 'maxres' = 'hq'
): string {
const qualityMap = {
default: 'default.jpg',
mq: 'mqdefault.jpg',
hq: 'hqdefault.jpg',
sd: 'sddefault.jpg',
maxres: 'maxresdefault.jpg',
};
return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}`;
}
/**
* Detect video platform from URL
*/
export function detectVideoPlatform(url: string): VideoPlatform {
if (!url) return 'external';
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
if (url.includes('vimeo.com')) return 'vimeo';
if (url.includes('dailymotion.com')) return 'dailymotion';
if (url.includes('twitch.tv')) return 'twitch';
if (url.match(/\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv)$/i)) return 'uploaded';
return 'external';
}
/**
* Generate YouTube embed URL with privacy-enhanced mode and parameters
*/
export function generateYouTubeEmbedUrl(
videoId: string,
options: {
autoplay?: boolean;
controls?: boolean;
modestbranding?: boolean;
rel?: boolean;
playsinline?: boolean;
start?: number;
end?: number;
} = {}
): string {
const {
autoplay = false,
controls = true,
modestbranding = true,
rel = false,
playsinline = true,
start,
end,
} = options;
const params = new URLSearchParams({
autoplay: autoplay ? '1' : '0',
controls: controls ? '1' : '0',
modestbranding: modestbranding ? '1' : '0',
rel: rel ? '1' : '0',
playsinline: playsinline ? '1' : '0',
});
if (start !== undefined) params.set('start', start.toString());
if (end !== undefined) params.set('end', end.toString());
// Use privacy-enhanced mode (youtube-nocookie.com)
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`;
}
/**
* Get CSS classes for video positioning
*/
export function getVideoPositionClasses(position: VideoPosition): string {
switch (position) {
case 'top':
return 'mb-8';
case 'inline':
return 'my-6';
case 'bottom':
return 'mt-8';
case 'none':
return '';
default:
return 'my-6';
}
}
/**
* Validate video URL
*/
export function validateVideoUrl(url: string): { isValid: boolean; platform: VideoPlatform; error?: string } {
if (!url) {
return { isValid: false, platform: 'external', error: 'Video URL is required' };
}
try {
new URL(url);
} catch {
return { isValid: false, platform: 'external', error: 'Invalid URL format' };
}
const platform = detectVideoPlatform(url);
if (platform === 'youtube' && !isValidYouTubeUrl(url)) {
return { isValid: false, platform: 'youtube', error: 'Invalid YouTube URL' };
}
return { isValid: true, platform };
}

4
todos.md Normal file
View File

@ -0,0 +1,4 @@
1. role based auth [admin, contributor, user]
2. [ ] video
3. social media integration [telegram, whatsup, facebook, viber]
4. share with functionality