From 89b86874316292c4fd7babed2ede446d68432615 Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 3 Feb 2026 21:18:25 +0100 Subject: [PATCH] video func implemented --- backend/src/modules/articles.dto.ts | 50 +++++- backend/src/modules/articles.service.ts | 16 +- backend/src/modules/entities.ts | 31 ++++ backend/src/modules/live-blog.service.ts | 12 ++ backend/src/modules/strapi.service.ts | 30 +++- .../article/content-types/article/schema.json | 14 ++ cms/cms/types/generated/contentTypes.d.ts | 12 ++ .../routes/ArticleDetailComponent.tsx | 39 ++++- frontend/src/components/ui/youtube-embed.tsx | 134 +++++++++++++++ frontend/src/hooks/useLiveBlogStream.ts | 8 +- frontend/src/lib/api.ts | 6 + frontend/src/lib/video-utils.ts | 157 ++++++++++++++++++ todos.md | 4 + 13 files changed, 504 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/ui/youtube-embed.tsx create mode 100644 frontend/src/lib/video-utils.ts create mode 100644 todos.md diff --git a/backend/src/modules/articles.dto.ts b/backend/src/modules/articles.dto.ts index 70b8ba4..116147c 100644 --- a/backend/src/modules/articles.dto.ts +++ b/backend/src/modules/articles.dto.ts @@ -8,7 +8,7 @@ import { IsBoolean, IsDate, } from 'class-validator'; -import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize } from './entities'; +import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize, VideoPosition } from './entities'; export class CreateArticleDto { @IsString() @@ -57,6 +57,18 @@ export class CreateArticleDto { @IsOptional() @IsEnum(ImageSize) imageSize?: ImageSize; + + @IsOptional() + @IsString() + videoUrl?: string; + + @IsOptional() + @IsEnum(VideoPosition) + videoPosition?: VideoPosition; + + @IsOptional() + @IsString() + videoCaption?: string; } export class UpdateArticleDto { @@ -108,6 +120,18 @@ export class UpdateArticleDto { @IsOptional() @IsEnum(ImageSize) imageSize?: ImageSize; + + @IsOptional() + @IsString() + videoUrl?: string; + + @IsOptional() + @IsEnum(VideoPosition) + videoPosition?: VideoPosition; + + @IsOptional() + @IsString() + videoCaption?: string; } export class FindArticlesDto { @@ -181,6 +205,18 @@ export class CreateLiveBlogDto { @IsOptional() @IsEnum(ImageSize) imageSize?: ImageSize; + + @IsOptional() + @IsString() + videoUrl?: string; + + @IsOptional() + @IsEnum(VideoPosition) + videoPosition?: VideoPosition; + + @IsOptional() + @IsString() + videoCaption?: string; } export class UpdateLiveBlogDto { @@ -227,6 +263,18 @@ export class UpdateLiveBlogDto { @IsOptional() @IsEnum(ImageSize) imageSize?: ImageSize; + + @IsOptional() + @IsString() + videoUrl?: string; + + @IsOptional() + @IsEnum(VideoPosition) + videoPosition?: VideoPosition; + + @IsOptional() + @IsString() + videoCaption?: string; } export class CreateLiveBlogUpdateDto { diff --git a/backend/src/modules/articles.service.ts b/backend/src/modules/articles.service.ts index 98cc0fe..955e0a5 100644 --- a/backend/src/modules/articles.service.ts +++ b/backend/src/modules/articles.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Article, ArticleStatus } from './entities'; @@ -10,6 +10,8 @@ import { @Injectable() export class ArticlesService { + private readonly logger = new Logger(ArticlesService.name); + constructor( @InjectRepository(Article) private readonly articleRepository: Repository
, @@ -125,4 +127,16 @@ export class ArticlesService { return await this.articleRepository.save(article); } + + async removeByStrapiId(strapiId: string): Promise { + 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}`); + } } diff --git a/backend/src/modules/entities.ts b/backend/src/modules/entities.ts index 9a89812..f9398c1 100644 --- a/backend/src/modules/entities.ts +++ b/backend/src/modules/entities.ts @@ -49,6 +49,13 @@ export enum ImageSize { LARGE = 'large', } +export enum VideoPosition { + TOP = 'top', + INLINE = 'inline', + BOTTOM = 'bottom', + NONE = 'none', +} + @Entity('authors') export class Author { @PrimaryGeneratedColumn('uuid') @@ -139,6 +146,18 @@ export class Article { }) imageSize: ImageSize; + @Column({ default: '' }) + videoUrl: string; + + @Column({ + type: 'text', + default: 'inline', + }) + videoPosition: VideoPosition; + + @Column({ default: '' }) + videoCaption: string; + @Column({ type: 'text', default: '[]', @@ -226,6 +245,18 @@ export class LiveBlog { }) imageSize: ImageSize; + @Column({ default: '' }) + videoUrl: string; + + @Column({ + type: 'text', + default: 'inline', + }) + videoPosition: VideoPosition; + + @Column({ default: '' }) + videoCaption: string; + @Column({ default: 0 }) viewCount: number; diff --git a/backend/src/modules/live-blog.service.ts b/backend/src/modules/live-blog.service.ts index 2bf17bc..4db1b2c 100644 --- a/backend/src/modules/live-blog.service.ts +++ b/backend/src/modules/live-blog.service.ts @@ -442,6 +442,18 @@ export class LiveBlogService implements OnModuleInit { return await this.liveBlogRepository.save(liveBlog); } + async removeByStrapiId(strapiId: string): Promise { + 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 async getLiveBlogsWithRecentUpdates(hours = 24): Promise { const since = new Date(); diff --git a/backend/src/modules/strapi.service.ts b/backend/src/modules/strapi.service.ts index 70bdc75..225a74f 100644 --- a/backend/src/modules/strapi.service.ts +++ b/backend/src/modules/strapi.service.ts @@ -5,7 +5,7 @@ import { lastValueFrom } from 'rxjs'; import { ArticlesService } from './articles.service'; import { LiveBlogService } from './live-blog.service'; import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto'; -import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize } from './entities'; +import { ArticleStatus, LiveBlogStatus, ImagePosition, ImageSize, VideoPosition } from './entities'; interface StrapiArticle { id: number; @@ -21,6 +21,9 @@ interface StrapiArticle { media?: any[]; imagePosition?: string; imageSize?: string; + videoUrl?: string; + videoPosition?: string; + videoCaption?: string; } interface StrapiLiveBlog { @@ -37,6 +40,9 @@ interface StrapiLiveBlog { media?: any[]; imagePosition?: string; imageSize?: string; + videoUrl?: string; + videoPosition?: string; + videoCaption?: string; } interface StrapiResponse { @@ -162,6 +168,9 @@ export class StrapiService { featuredImage: imageUrl, imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition, imageSize: (strapiArticle.imageSize || 'medium') as ImageSize, + videoUrl: strapiArticle.videoUrl || '', + videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition, + videoCaption: strapiArticle.videoCaption || '', }; await this.articlesService.syncFromStrapi( @@ -223,6 +232,9 @@ export class StrapiService { featuredImage: imageUrl, imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition, imageSize: (strapiArticle.imageSize || 'medium') as ImageSize, + videoUrl: strapiArticle.videoUrl || '', + videoPosition: (strapiArticle.videoPosition || 'inline') as VideoPosition, + videoCaption: strapiArticle.videoCaption || '', }; await this.articlesService.syncFromStrapi( @@ -287,6 +299,9 @@ export class StrapiService { featuredImage: imageUrl, imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition, imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize, + videoUrl: strapiLiveBlog.videoUrl || '', + videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition, + videoCaption: strapiLiveBlog.videoCaption || '', }; await this.liveBlogService.syncFromStrapi( @@ -342,6 +357,9 @@ export class StrapiService { featuredImage: imageUrl, imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition, imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize, + videoUrl: strapiLiveBlog.videoUrl || '', + videoPosition: (strapiLiveBlog.videoPosition || 'inline') as VideoPosition, + videoCaption: strapiLiveBlog.videoCaption || '', }; await this.liveBlogService.syncFromStrapi( @@ -403,7 +421,15 @@ export class StrapiService { ); 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; } diff --git a/cms/cms/src/api/article/content-types/article/schema.json b/cms/cms/src/api/article/content-types/article/schema.json index 595f815..39a0d34 100644 --- a/cms/cms/src/api/article/content-types/article/schema.json +++ b/cms/cms/src/api/article/content-types/article/schema.json @@ -49,6 +49,20 @@ "type": "enumeration", "enum": ["small", "medium", "large"], "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": "" } } } diff --git a/cms/cms/types/generated/contentTypes.d.ts b/cms/cms/types/generated/contentTypes.d.ts index 3e603a7..bb76347 100644 --- a/cms/cms/types/generated/contentTypes.d.ts +++ b/cms/cms/types/generated/contentTypes.d.ts @@ -446,6 +446,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema { createdAt: Schema.Attribute.DateTime; createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & 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'>; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation< @@ -462,6 +468,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema { updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & 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<''>; }; } diff --git a/frontend/src/components/routes/ArticleDetailComponent.tsx b/frontend/src/components/routes/ArticleDetailComponent.tsx index 2d05c0c..ddb62da 100644 --- a/frontend/src/components/routes/ArticleDetailComponent.tsx +++ b/frontend/src/components/routes/ArticleDetailComponent.tsx @@ -3,6 +3,8 @@ import { Link } from '@tanstack/react-router' import * as api from '@/lib/api' import ReactMarkdown from 'react-markdown' 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 }) { const { data, isLoading, error } = useQuery({ @@ -110,6 +112,21 @@ export function ArticleDetailComponent({ id }: { id: string }) { )} + {/* Video rendering */} + {data.videoUrl && data.videoPosition !== 'none' && ( +
+ +
+ )} +
- ) + ), + a: (props) => { + // Check if the link is a YouTube URL + const videoId = extractYouTubeVideoId(props.href || ''); + if (videoId) { + return ( +
+ +
+ ); + } + // Regular link + return ; + } }} > {data.content} diff --git a/frontend/src/components/ui/youtube-embed.tsx b/frontend/src/components/ui/youtube-embed.tsx new file mode 100644 index 0000000..d7b43b1 --- /dev/null +++ b/frontend/src/components/ui/youtube-embed.tsx @@ -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 ( +
+

+ Invalid YouTube URL: {url} +

+
+ ); + } + + // Generate embed URL with options + const embedUrl = generateYouTubeEmbedUrl(videoId, { + autoplay, + controls, + modestbranding, + rel: showRelated, + playsinline: true, + start: startTime, + end: endTime, + }); + + return ( +
+ {/* Video container with aspect ratio */} +
+ {/* Loading state */} + {showLoading && !isLoaded && !hasError && ( +
+
+
+ )} + + {/* Error state */} + {hasError && ( +
+
+ + + +
+

Video failed to load

+

+ The YouTube video could not be loaded. Please check the URL or try again later. +

+
+ )} + + {/* YouTube iframe */} + {!hasError && ( +