5 Commits
Author SHA1 Message Date
dimitar 9ca05f5ea1 delete/archive implemented 2026-02-03 23:14:36 +01:00
dimitar 89b8687431 video func implemented 2026-02-03 21:18:25 +01:00
dimitar 3bc534489d img positioning fixed 2026-02-03 20:03:46 +01:00
dimitar 6467e21019 markdown img insert work as intended 2026-02-03 19:53:29 +01:00
dimitar 28609a6492 img shown succesfully 2026-02-03 18:47:42 +01:00
22 changed files with 4308 additions and 1327 deletions

No files matched your search

@@ -4,6 +4,7 @@ import {
Post, Post,
Put, Put,
Delete, Delete,
Patch,
Body, Body,
Param, Param,
Query, Query,
@@ -15,6 +16,7 @@ import {
UpdateArticleDto, UpdateArticleDto,
FindArticlesDto, FindArticlesDto,
} from './articles.dto'; } from './articles.dto';
import { ArticleStatus } from './entities';
@Controller('articles') @Controller('articles')
export class ArticlesController { export class ArticlesController {
@@ -54,4 +56,17 @@ export class ArticlesController {
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.articlesService.remove(id); return this.articlesService.remove(id);
} }
@Patch(':id/archive')
archive(@Param('id') id: string) {
return this.articlesService.archive(id);
}
@Patch(':id/publish')
publish(
@Param('id') id: string,
@Query('status') status?: ArticleStatus,
) {
return this.articlesService.publish(id, status || ArticleStatus.PUBLISHED);
}
} }
+97 -3
View File
@@ -8,7 +8,13 @@ import {
IsBoolean, IsBoolean,
IsDate, IsDate,
} from 'class-validator'; } from 'class-validator';
import { ArticleStatus, LiveBlogStatus } from './entities'; import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto { export class CreateArticleDto {
@IsString() @IsString()
@@ -49,6 +55,26 @@ export class CreateArticleDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class UpdateArticleDto { export class UpdateArticleDto {
@@ -92,6 +118,26 @@ export class UpdateArticleDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class FindArticlesDto { export class FindArticlesDto {
@@ -108,8 +154,8 @@ export class FindArticlesDto {
tag?: string; tag?: string;
@IsOptional() @IsOptional()
@IsEnum(ArticleStatus) @IsString()
status?: ArticleStatus; status?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -153,6 +199,30 @@ export class CreateLiveBlogDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class UpdateLiveBlogDto { export class UpdateLiveBlogDto {
@@ -187,6 +257,30 @@ export class UpdateLiveBlogDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsEnum(ImagePosition)
imagePosition?: ImagePosition;
@IsOptional()
@IsEnum(ImageSize)
imageSize?: ImageSize;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsEnum(VideoPosition)
videoPosition?: VideoPosition;
@IsOptional()
@IsString()
videoCaption?: string;
} }
export class CreateLiveBlogUpdateDto { export class CreateLiveBlogUpdateDto {
+42 -12
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>,
@@ -26,21 +28,23 @@ export class ArticlesService {
async findAll( async findAll(
dto: FindArticlesDto, dto: FindArticlesDto,
): Promise<{ data: Article[]; total: number }> { ): Promise<{ data: Article[]; total: number }> {
const { const { category, author, tag, status, search, page = 1, limit = 10 } = dto;
category,
author,
tag,
status = ArticleStatus.PUBLISHED,
search,
page = 1,
limit = 10,
} = dto;
const queryBuilder = this.articleRepository const queryBuilder = this.articleRepository
.createQueryBuilder('article') .createQueryBuilder('article')
.leftJoinAndSelect('article.author', 'author') .leftJoinAndSelect('article.author', 'author')
.leftJoinAndSelect('article.category', 'category') .leftJoinAndSelect('article.category', 'category');
.where('article.status = :status', { status });
// Handle status filter - can be single value or comma-separated list
if (status) {
if (typeof status === 'string' && status.includes(',')) {
const statuses = status.split(',').map((s) => s.trim());
queryBuilder.where('article.status IN (:...statuses)', { statuses });
} else {
queryBuilder.where('article.status = :status', { status });
}
}
// If no status specified, return all articles (for admin dashboard)
if (category) { if (category) {
queryBuilder.andWhere('category.slug = :category', { category }); queryBuilder.andWhere('category.slug = :category', { category });
@@ -107,6 +111,18 @@ export class ArticlesService {
await this.articleRepository.remove(article); await this.articleRepository.remove(article);
} }
async archive(id: string): Promise<Article> {
const article = await this.findOne(id);
article.status = ArticleStatus.ARCHIVED;
return await this.articleRepository.save(article);
}
async publish(id: string, status: ArticleStatus = ArticleStatus.PUBLISHED): Promise<Article> {
const article = await this.findOne(id);
article.status = status;
return await this.articleRepository.save(article);
}
async syncFromStrapi( async syncFromStrapi(
strapiId: string, strapiId: string,
data: Partial<CreateArticleDto>, data: Partial<CreateArticleDto>,
@@ -125,4 +141,18 @@ 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}`);
}
} }
+71
View File
@@ -36,6 +36,26 @@ export enum LiveBlogStatus {
ARCHIVED = 'archived', ARCHIVED = 'archived',
} }
export enum ImagePosition {
TOP = 'top',
LEFT = 'left',
RIGHT = 'right',
NONE = 'none',
}
export enum ImageSize {
SMALL = 'small',
MEDIUM = 'medium',
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')
@@ -114,6 +134,30 @@ export class Article {
@Column({ default: '' }) @Column({ default: '' })
featuredImage: string; featuredImage: string;
@Column({
type: 'text',
default: 'top',
})
imagePosition: ImagePosition;
@Column({
type: 'text',
default: 'medium',
})
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: '[]',
@@ -186,6 +230,33 @@ export class LiveBlog {
@Column({ nullable: true }) @Column({ nullable: true })
categoryId: string; categoryId: string;
@Column({ default: '' })
featuredImage: string;
@Column({
type: 'text',
default: 'top',
})
imagePosition: ImagePosition;
@Column({
type: 'text',
default: 'medium',
})
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;
@@ -4,6 +4,7 @@ import {
Post, Post,
Put, Put,
Delete, Delete,
Patch,
Body, Body,
Param, Param,
Query, Query,
@@ -21,6 +22,7 @@ import {
CreateLiveBlogUpdateDto, CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto, UpdateLiveBlogUpdateDto,
} from './articles.dto'; } from './articles.dto';
import { LiveBlogStatus } from './entities';
@Controller('live-blogs') @Controller('live-blogs')
export class LiveBlogController { export class LiveBlogController {
@@ -121,6 +123,19 @@ export class LiveBlogController {
return this.liveBlogService.removeUpdate(liveBlogId, updateId); return this.liveBlogService.removeUpdate(liveBlogId, updateId);
} }
@Patch(':id/archive')
archive(@Param('id') id: string) {
return this.liveBlogService.archive(id);
}
@Patch(':id/publish')
publish(
@Param('id') id: string,
@Query('status') status?: LiveBlogStatus,
) {
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
}
// SSE endpoint for real-time updates // SSE endpoint for real-time updates
@Get(':id/stream') @Get(':id/stream')
stream( stream(
+31 -7
View File
@@ -116,13 +116,9 @@ export class LiveBlogService implements OnModuleInit {
} else { } else {
queryBuilder.where('liveBlog.status = :status', { status }); queryBuilder.where('liveBlog.status = :status', { status });
} }
} else if (!isPinned) {
// Default to live blogs if no status specified AND not querying for pinned blogs
// Pinned blogs can be either live or ended
queryBuilder.where('liveBlog.status = :status', {
status: LiveBlogStatus.LIVE,
});
} }
// If no status specified, return all live blogs (for admin dashboard)
// Note: Pinned blogs query should still work without status filter
if (category) { if (category) {
queryBuilder.andWhere('category.slug = :category', { category }); queryBuilder.andWhere('category.slug = :category', { category });
@@ -262,6 +258,18 @@ export class LiveBlogService implements OnModuleInit {
await this.liveBlogRepository.remove(liveBlog); await this.liveBlogRepository.remove(liveBlog);
} }
async archive(id: string): Promise<LiveBlog> {
const liveBlog = await this.findOne(id);
liveBlog.status = LiveBlogStatus.ARCHIVED;
return await this.liveBlogRepository.save(liveBlog);
}
async publish(id: string, status: LiveBlogStatus = LiveBlogStatus.DRAFT): Promise<LiveBlog> {
const liveBlog = await this.findOne(id);
liveBlog.status = status;
return await this.liveBlogRepository.save(liveBlog);
}
// Live Blog Update CRUD operations // Live Blog Update CRUD operations
async createUpdate( async createUpdate(
dto: CreateLiveBlogUpdateDto, dto: CreateLiveBlogUpdateDto,
@@ -380,7 +388,7 @@ export class LiveBlogService implements OnModuleInit {
const keepAliveInterval = setInterval(() => { const keepAliveInterval = setInterval(() => {
try { try {
response.write(`: keep-alive\n\n`); response.write(`: keep-alive\n\n`);
} catch (error) { } catch {
// Client disconnected, stop sending keep-alive // Client disconnected, stop sending keep-alive
clearInterval(keepAliveInterval); clearInterval(keepAliveInterval);
} }
@@ -442,6 +450,22 @@ 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();
+6 -1
View File
@@ -2,7 +2,12 @@ import { Controller, Post, Body, Logger } from '@nestjs/common';
import { StrapiService } from './strapi.service'; import { StrapiService } from './strapi.service';
interface WebhookBody { interface WebhookBody {
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish'; event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish';
model: string; model: string;
entry: { entry: {
documentId: string; documentId: string;
+170 -17
View File
@@ -5,7 +5,13 @@ 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 } from './entities'; import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
interface StrapiArticle { interface StrapiArticle {
id: number; id: number;
@@ -17,6 +23,13 @@ interface StrapiArticle {
publishedAt: string | null; publishedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
img?: any;
media?: any[];
imagePosition?: string;
imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
} }
interface StrapiLiveBlog { interface StrapiLiveBlog {
@@ -29,6 +42,13 @@ interface StrapiLiveBlog {
publishedAt: string | null; publishedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
img?: any;
media?: any[];
imagePosition?: string;
imageSize?: string;
videoUrl?: string;
videoPosition?: string;
videoCaption?: string;
} }
interface StrapiResponse<T> { interface StrapiResponse<T> {
@@ -71,13 +91,67 @@ export class StrapiService {
return headers; return headers;
} }
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
// Try to get image from img field first (single image)
let imageUrl: string | undefined;
if (strapiArticle.img?.url) {
imageUrl = strapiArticle.img.url;
} else if (strapiArticle.media?.[0]?.url) {
// Try to get first image from media field (multiple images)
imageUrl = strapiArticle.media[0].url;
}
if (!imageUrl) {
return undefined;
}
// If URL is relative, prepend Strapi base URL
if (imageUrl.startsWith('/')) {
// Convert Docker service URL to localhost for frontend access
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
return `${frontendStrapiUrl}${imageUrl}`;
}
return imageUrl;
}
private extractLiveBlogImageUrl(
strapiLiveBlog: StrapiLiveBlog,
): string | undefined {
// Try to get image from img field first (single image)
let imageUrl: string | undefined;
if (strapiLiveBlog.img?.url) {
imageUrl = strapiLiveBlog.img.url;
} else if (strapiLiveBlog.media?.[0]?.url) {
// Try to get first image from media field (multiple images)
imageUrl = strapiLiveBlog.media[0].url;
}
if (!imageUrl) {
return undefined;
}
// If URL is relative, prepend Strapi base URL
if (imageUrl.startsWith('/')) {
// Convert Docker service URL to localhost for frontend access
// Backend uses http://cms:1337 internally, but frontend needs http://localhost:1337
const frontendStrapiUrl = this.strapiUrl.replace('cms:', 'localhost:');
return `${frontendStrapiUrl}${imageUrl}`;
}
return imageUrl;
}
async syncArticles(): Promise<void> { async syncArticles(): Promise<void> {
try { try {
this.logger.log('Starting articles sync from Strapi...'); this.logger.log('Starting articles sync from Strapi...');
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle[]>>( this.httpService.get<StrapiResponse<StrapiArticle[]>>(
`${this.strapiUrl}/api/articles`, `${this.strapiUrl}/api/articles?populate=*`,
{ {
headers: this.getHeaders(), headers: this.getHeaders(),
}, },
@@ -88,6 +162,8 @@ export class StrapiService {
let syncedCount = 0; let syncedCount = 0;
for (const strapiArticle of strapiArticles) { for (const strapiArticle of strapiArticles) {
const imageUrl = this.extractImageUrl(strapiArticle);
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -97,6 +173,14 @@ export class StrapiService {
? ArticleStatus.PUBLISHED ? ArticleStatus.PUBLISHED
: ArticleStatus.DRAFT, : ArticleStatus.DRAFT,
tags: [], tags: [],
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( await this.articlesService.syncFromStrapi(
@@ -117,14 +201,21 @@ export class StrapiService {
async syncSingleArticle( async syncSingleArticle(
strapiId: string, strapiId: string,
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> { ): Promise<void> {
try { try {
this.logger.log(`Syncing single article from Strapi: ${strapiId}, event: ${event}`); this.logger.log(
`Syncing single article from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle>>( this.httpService.get<StrapiResponse<StrapiArticle>>(
`${this.strapiUrl}/api/articles/${strapiId}`, `${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
{ {
headers: this.getHeaders(), headers: this.getHeaders(),
}, },
@@ -146,6 +237,8 @@ export class StrapiService {
status = ArticleStatus.DRAFT; status = ArticleStatus.DRAFT;
} }
const imageUrl = this.extractImageUrl(strapiArticle);
const articleData: Partial<CreateArticleDto> = { const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title, title: strapiArticle.title,
excerpt: strapiArticle.description, excerpt: strapiArticle.description,
@@ -153,17 +246,28 @@ export class StrapiService {
slug: strapiArticle.slug, slug: strapiArticle.slug,
status, status,
tags: [], tags: [],
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( await this.articlesService.syncFromStrapi(
strapiArticle.documentId, strapiArticle.documentId,
articleData, articleData,
); );
this.logger.log(`Successfully synced article: ${strapiArticle.title} with status: ${status}`); this.logger.log(
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
);
} catch (error: any) { } catch (error: any) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft // If we get a 404 and it's an unpublish event, we can still mark it as draft
if (event === 'entry.unpublish' && error.response?.status === 404) { if (event === 'entry.unpublish' && error.response?.status === 404) {
this.logger.log(`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`); this.logger.log(
`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
);
// Try to update the article status to draft directly // Try to update the article status to draft directly
try { try {
@@ -172,7 +276,9 @@ export class StrapiService {
}; };
await this.articlesService.syncFromStrapi(strapiId, articleData); await this.articlesService.syncFromStrapi(strapiId, articleData);
this.logger.log(`Marked article ${strapiId} as draft based on unpublish event`); this.logger.log(
`Marked article ${strapiId} as draft based on unpublish event`,
);
} catch (syncError) { } catch (syncError) {
this.logger.error( this.logger.error(
`Failed to mark article ${strapiId} as draft:`, `Failed to mark article ${strapiId} as draft:`,
@@ -196,7 +302,7 @@ export class StrapiService {
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>( this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
`${this.strapiUrl}/api/live-blogs`, `${this.strapiUrl}/api/live-blogs?populate=*`,
{ {
headers: this.getHeaders(), headers: this.getHeaders(),
}, },
@@ -207,11 +313,21 @@ export class StrapiService {
let syncedCount = 0; let syncedCount = 0;
for (const strapiLiveBlog of strapiLiveBlogs) { for (const strapiLiveBlog of strapiLiveBlogs) {
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug, slug: strapiLiveBlog.slug,
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status), status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
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( await this.liveBlogService.syncFromStrapi(
@@ -232,14 +348,21 @@ export class StrapiService {
async syncSingleLiveBlog( async syncSingleLiveBlog(
strapiId: string, strapiId: string,
event?: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', event?:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
): Promise<void> { ): Promise<void> {
try { try {
this.logger.log(`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`); this.logger.log(
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
);
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog>>( this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
`${this.strapiUrl}/api/live-blogs/${strapiId}`, `${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
{ {
headers: this.getHeaders(), headers: this.getHeaders(),
}, },
@@ -257,22 +380,35 @@ export class StrapiService {
status = LiveBlogStatus.DRAFT; status = LiveBlogStatus.DRAFT;
} }
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
const liveBlogData: Partial<CreateLiveBlogDto> = { const liveBlogData: Partial<CreateLiveBlogDto> = {
title: strapiLiveBlog.title, title: strapiLiveBlog.title,
description: strapiLiveBlog.description, description: strapiLiveBlog.description,
slug: strapiLiveBlog.slug, slug: strapiLiveBlog.slug,
status, status,
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( await this.liveBlogService.syncFromStrapi(
strapiLiveBlog.documentId, strapiLiveBlog.documentId,
liveBlogData, liveBlogData,
); );
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`); this.logger.log(
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
);
} catch (error: any) { } catch (error: any) {
// If we get a 404 and it's an unpublish event, we can still mark it as draft // If we get a 404 and it's an unpublish event, we can still mark it as draft
if (event === 'entry.unpublish' && error.response?.status === 404) { if (event === 'entry.unpublish' && error.response?.status === 404) {
this.logger.log(`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`); this.logger.log(
`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
);
// Try to update the live blog status to draft directly // Try to update the live blog status to draft directly
try { try {
@@ -281,7 +417,9 @@ export class StrapiService {
}; };
await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData); await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData);
this.logger.log(`Marked live blog ${strapiId} as draft based on unpublish event`); this.logger.log(
`Marked live blog ${strapiId} as draft based on unpublish event`,
);
} catch (syncError) { } catch (syncError) {
this.logger.error( this.logger.error(
`Failed to mark live blog ${strapiId} as draft:`, `Failed to mark live blog ${strapiId} as draft:`,
@@ -315,7 +453,12 @@ export class StrapiService {
} }
async handleWebhook( async handleWebhook(
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish' | 'entry.unpublish', event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
data: { documentId: string; model?: string }, data: { documentId: string; model?: string },
): Promise<void> { ): Promise<void> {
this.logger.log( this.logger.log(
@@ -323,7 +466,17 @@ 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;
} }
@@ -39,6 +39,30 @@
"videos", "videos",
"audios" "audios"
] ]
},
"imagePosition": {
"type": "enumeration",
"enum": ["top", "left", "right", "none"],
"default": "top"
},
"imageSize": {
"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": ""
} }
} }
} }
+12
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<''>;
}; };
} }
+1471 -22
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -28,7 +28,9 @@
"@tanstack/react-router": "^1.144.0", "@tanstack/react-router": "^1.144.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useLiveBlogs } from '@/queries/live-blogs'; import { useLiveBlogs } from '@/queries/live-blogs';
import { useArticles } from '@/queries/articles'; import { useArticles, useDeleteArticle, useArchiveArticle, usePublishArticle } from '@/queries/articles';
import { useDeleteLiveBlog, useArchiveLiveBlog, usePublishLiveBlog } from '@/queries/live-blogs';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -8,12 +10,98 @@ import { format } from 'date-fns';
import { mk } from 'date-fns/locale'; import { mk } from 'date-fns/locale';
export function AdminDashboardComponent() { export function AdminDashboardComponent() {
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({ limit: 50 }); // State for confirmation dialog and filters
const { data: articlesData, isLoading: loadingArticles } = useArticles({ limit: 50 }); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogType, setDialogType] = useState<'delete' | 'archive'>('delete');
const [itemToDelete, setItemToDelete] = useState<{
type: 'article' | 'liveBlog';
id: string;
title: string;
} | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const { data: liveBlogsData, isLoading: loadingLiveBlogs } = useLiveBlogs({
limit: 50,
status: showArchived ? 'archived' : 'draft,live,ended'
});
const { data: articlesData, isLoading: loadingArticles } = useArticles({
limit: 50,
status: showArchived ? 'archived' : 'draft,published'
});
const deleteArticleMutation = useDeleteArticle();
const deleteLiveBlogMutation = useDeleteLiveBlog();
const archiveArticleMutation = useArchiveArticle();
const archiveLiveBlogMutation = useArchiveLiveBlog();
const publishArticleMutation = usePublishArticle();
const publishLiveBlogMutation = usePublishLiveBlog();
const liveBlogs = liveBlogsData?.data || []; const liveBlogs = liveBlogsData?.data || [];
const articles = articlesData?.data || []; const articles = articlesData?.data || [];
// No need to filter items - API already filters based on showArchived state
const filteredLiveBlogs = liveBlogs;
const filteredArticles = articles;
const handleDeleteClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title });
setDialogType('delete');
setShowConfirmDialog(true);
};
const handleArchiveClick = (type: 'article' | 'liveBlog', id: string, title: string) => {
setItemToDelete({ type, id, title });
setDialogType('archive');
setShowConfirmDialog(true);
};
const handlePublishClick = async (type: 'article' | 'liveBlog', id: string) => {
setIsProcessing(true);
try {
if (type === 'article') {
await publishArticleMutation.mutateAsync({ id, status: 'published' });
} else {
await publishLiveBlogMutation.mutateAsync({ id, status: 'draft' });
}
} catch (error) {
console.error('Failed to publish:', error);
} finally {
setIsProcessing(false);
}
};
const handleConfirmAction = async () => {
if (!itemToDelete) return;
setIsProcessing(true);
try {
if (dialogType === 'delete') {
if (itemToDelete.type === 'article') {
await deleteArticleMutation.mutateAsync(itemToDelete.id);
} else {
await deleteLiveBlogMutation.mutateAsync(itemToDelete.id);
}
} else { // archive
if (itemToDelete.type === 'article') {
await archiveArticleMutation.mutateAsync(itemToDelete.id);
} else {
await archiveLiveBlogMutation.mutateAsync(itemToDelete.id);
}
}
setShowConfirmDialog(false);
setItemToDelete(null);
} catch (error) {
console.error(`Failed to ${dialogType}:`, error);
} finally {
setIsProcessing(false);
}
};
const handleCancelAction = () => {
setShowConfirmDialog(false);
setItemToDelete(null);
};
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'published': case 'published':
@@ -50,6 +138,12 @@ export function AdminDashboardComponent() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
variant={showArchived ? "default" : "outline"}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? 'Прикажи активни' : 'Прикажи архивирани'}
</Button>
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link to="/admin/live-blogs/create">+ Нов Live блог</Link> <Link to="/admin/live-blogs/create">+ Нов Live блог</Link>
</Button> </Button>
@@ -64,9 +158,9 @@ export function AdminDashboardComponent() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span>Live блогови</span> <span>{showArchived ? 'Архивирани Live блогови' : 'Live блогови'}</span>
<Badge variant="outline" className="ml-2"> <Badge variant="outline" className="ml-2">
{liveBlogs.length || 0} {filteredLiveBlogs.length || 0}
</Badge> </Badge>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -79,16 +173,20 @@ export function AdminDashboardComponent() {
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : liveBlogs.length === 0 ? ( ) : filteredLiveBlogs.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">Нема live блогови</p> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани live блогови' : 'Нема live блогови'}
</p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4"> <Button asChild variant="outline" className="mt-4">
<Link to="/admin/live-blogs/create">Креирај нов live блог</Link> <Link to="/admin/live-blogs/create">Креирај нов live блог</Link>
</Button> </Button>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{liveBlogs.map((blog) => ( {filteredLiveBlogs.map((blog) => (
<div <div
key={blog.id} key={blog.id}
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
@@ -97,7 +195,8 @@ export function AdminDashboardComponent() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Link <Link
to={`/admin/live-blogs/${blog.slug}` as any} to="/admin/live-blogs/$slug"
params={{ slug: blog.slug }}
className="font-medium hover:text-primary hover:underline" className="font-medium hover:text-primary hover:underline"
> >
{blog.title} {blog.title}
@@ -123,13 +222,40 @@ export function AdminDashboardComponent() {
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<Link to={`/admin/live-blogs/${blog.slug}` as any}>Уреди</Link> <Link to="/admin/live-blogs/$slug" params={{ slug: blog.slug }}>Уреди</Link>
</Button> </Button>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link to={`/live-blogs/${blog.slug}` as any} target="_blank"> <Link to="/live-blogs/$slug" params={{ slug: blog.slug }} target="_blank">
Преглед Преглед
</Link> </Link>
</Button> </Button>
{showArchived ? (
<Button
size="sm"
variant="outline"
onClick={() => handlePublishClick('liveBlog', blog.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleArchiveClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('liveBlog', blog.id, blog.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -143,9 +269,9 @@ export function AdminDashboardComponent() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span>Написи</span> <span>{showArchived ? 'Архивирани написи' : 'Написи'}</span>
<Badge variant="outline" className="ml-2"> <Badge variant="outline" className="ml-2">
{articles.length || 0} {filteredArticles.length || 0}
</Badge> </Badge>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@@ -158,16 +284,20 @@ export function AdminDashboardComponent() {
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Вчитување...</p> <p className="mt-2 text-sm text-muted-foreground">Вчитување...</p>
</div> </div>
) : articles.length === 0 ? ( ) : filteredArticles.length === 0 ? (
<div className="text-center py-8 border rounded-lg"> <div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">Нема написи</p> <p className="text-muted-foreground">
{showArchived ? 'Нема архивирани написи' : 'Нема написи'}
</p>
{!showArchived && (
<Button asChild variant="outline" className="mt-4"> <Button asChild variant="outline" className="mt-4">
<Link to="/">Креирај нов напис</Link> <Link to="/">Креирај нов напис</Link>
</Button> </Button>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{articles.map((article) => ( {filteredArticles.map((article) => (
<div <div
key={article.id} key={article.id}
className="p-4 border rounded-lg hover:bg-accent/50 transition-colors" className="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
@@ -176,7 +306,8 @@ export function AdminDashboardComponent() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Link <Link
to={`/articles/${article.id}` as any} to="/articles/$id"
params={{ id: article.id }}
className="font-medium hover:text-primary hover:underline" className="font-medium hover:text-primary hover:underline"
> >
{article.title} {article.title}
@@ -202,13 +333,40 @@ export function AdminDashboardComponent() {
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-2 ml-4">
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<Link to={`/articles/${article.id}` as any}>Уреди</Link> <Link to="/articles/$id" params={{ id: article.id }}>Уреди</Link>
</Button> </Button>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link to={`/articles/${article.id}` as any} target="_blank"> <Link to="/articles/$id" params={{ id: article.id }} target="_blank">
Преглед Преглед
</Link> </Link>
</Button> </Button>
{showArchived ? (
<Button
size="sm"
variant="outline"
onClick={() => handlePublishClick('article', article.id)}
disabled={isProcessing}
>
Објави
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleArchiveClick('article', article.id, article.title)}
disabled={isProcessing}
>
Архивирај
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick('article', article.id, article.title)}
disabled={isProcessing}
>
Избриши
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -219,7 +377,8 @@ export function AdminDashboardComponent() {
</Card> </Card>
</div> </div>
{/* Quick Stats */} {/* Quick Stats - Only show when not viewing archived items */}
{!showArchived && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
@@ -255,6 +414,37 @@ export function AdminDashboardComponent() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
)}
{/* Confirmation Dialog */}
{showConfirmDialog && itemToDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-2">
{dialogType === 'delete' ? 'Потврди бришење' : 'Потврди архивирање'}
</h3>
<p className="text-muted-foreground mb-4">
{dialogType === 'delete'
? `Дали сте сигурни дека сакате да го избришете "${itemToDelete.title}"?`
: `Дали сте сигурни дека сакате да го архивирате "${itemToDelete.title}"?`}
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancelAction} disabled={isProcessing}>
Откажи
</Button>
<Button
variant={dialogType === 'delete' ? 'destructive' : 'default'}
onClick={handleConfirmAction}
disabled={isProcessing}
>
{isProcessing
? (dialogType === 'delete' ? 'Бришење...' : 'Архивирање...')
: (dialogType === 'delete' ? 'Избриши' : 'Архивирај')}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -1,6 +1,10 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import * as api from '@/lib/api' 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 }) { export function ArticleDetailComponent({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -65,16 +69,101 @@ export function ArticleDetailComponent({ id }: { id: string }) {
)} )}
</div> </div>
{data.featuredImage && ( {data.featuredImage && data.imagePosition !== 'none' && (
<div className={`relative mb-4 ${
data.imagePosition === 'top'
? 'w-full mb-8'
: data.imagePosition === 'left'
? 'float-none md:float-left mr-0 md:mr-6'
: 'float-none md:float-right ml-0 md:ml-6'
}`}>
<img <img
src={data.featuredImage} src={data.featuredImage}
alt={data.title} alt={data.title}
className="w-full h-64 md:h-96 object-cover rounded-xl mb-8" className={`rounded-xl object-cover ${
data.imagePosition === 'top'
? data.imageSize === 'small'
? 'h-32'
: data.imageSize === 'medium'
? 'h-48'
: 'h-64 md:h-96'
: data.imageSize === 'small'
? 'w-full md:w-48 h-32'
: data.imageSize === 'medium'
? 'w-full md:w-64 h-48'
: 'w-full md:w-96 h-64'
}`}
onError={(e) => {
console.error('Failed to load image:', data.featuredImage, e);
e.currentTarget.style.display = 'none';
// Show fallback
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
if (fallback) {
fallback.style.display = 'flex';
}
}}
/> />
<div
className="absolute inset-0 bg-gray-100 rounded-xl flex items-center justify-center text-gray-400 hidden"
style={{ display: 'none' }}
>
<span>Image not available</span>
</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">
<p className="text-lg leading-relaxed mb-6">{data.content}</p> <div className="text-lg leading-relaxed mb-6">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
img: (props) => (
<img
{...props}
className="max-w-full h-auto rounded-lg my-4"
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}
</ReactMarkdown>
</div>
</div> </div>
{data.tags && Array.isArray(data.tags) && data.tags.length > 0 && ( {data.tags && Array.isArray(data.tags) && data.tags.length > 0 && (
@@ -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>
);
}
+4 -4
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');
} }
}; };
+126 -2
View File
@@ -11,6 +11,11 @@ export interface Article {
excerpt: string | null; excerpt: string | null;
slug: string; slug: string;
featuredImage: string; featuredImage: string;
imagePosition: 'top' | 'left' | 'right' | 'none';
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;
@@ -45,12 +50,48 @@ export interface FindArticlesParams {
category?: string; category?: string;
author?: string; author?: string;
tag?: string; tag?: string;
status?: 'draft' | 'published' | 'archived'; status?: string;
search?: string; search?: string;
page?: number; page?: number;
limit?: number; limit?: number;
} }
export interface CreateArticleDto {
title: string;
content: string;
excerpt?: string;
slug?: string;
featuredImage?: string;
tags?: string[];
status?: 'draft' | 'published' | 'archived';
strapiId?: string;
authorId?: string;
categoryId?: string;
imagePosition?: 'top' | 'left' | 'right' | 'none';
imageSize?: 'small' | 'medium' | 'large';
videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string;
}
export interface UpdateArticleDto {
title?: string;
content?: string;
excerpt?: string;
slug?: string;
featuredImage?: string;
tags?: string[];
status?: 'draft' | 'published' | 'archived';
strapiId?: string;
authorId?: string;
categoryId?: string;
imagePosition?: 'top' | 'left' | 'right' | 'none';
imageSize?: 'small' | 'medium' | 'large';
videoUrl?: string;
videoPosition?: 'top' | 'inline' | 'bottom' | 'none';
videoCaption?: string;
}
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> { export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL); console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL);
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@@ -97,6 +138,63 @@ export async function fetchArticleById(id: string): Promise<Article> {
return response.json(); return response.json();
} }
export async function createArticle(dto: CreateArticleDto): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to create article');
}
return response.json();
}
export async function updateArticle(id: string, dto: UpdateArticleDto): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to update article');
}
return response.json();
}
export async function deleteArticle(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete article');
}
}
export async function archiveArticle(id: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}/archive`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to archive article');
}
return response.json();
}
export async function publishArticle(id: string, status: 'draft' | 'published' = 'published'): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}/publish?status=${status}`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to publish article');
}
return response.json();
}
// Live Blog Types // Live Blog Types
export interface LiveBlogUpdate { export interface LiveBlogUpdate {
id: string; id: string;
@@ -126,6 +224,12 @@ export interface LiveBlog {
strapiId: string | null; strapiId: string | null;
authorId: string | null; authorId: string | null;
categoryId: string | null; categoryId: string | null;
featuredImage: string;
imagePosition: 'top' | 'left' | 'right' | 'none';
imageSize: 'small' | 'medium' | 'large';
videoUrl: string;
videoPosition: 'top' | 'inline' | 'bottom' | 'none';
videoCaption: string;
viewCount: number; viewCount: number;
author?: { author?: {
id: string; id: string;
@@ -162,7 +266,7 @@ export interface LiveBlogUpdatesResponse {
export interface FindLiveBlogsParams { export interface FindLiveBlogsParams {
category?: string; category?: string;
author?: string; author?: string;
status?: 'draft' | 'live' | 'ended' | 'archived'; status?: string;
search?: string; search?: string;
page?: number; page?: number;
limit?: number; limit?: number;
@@ -361,3 +465,23 @@ export async function deleteLiveBlog(id: string): Promise<void> {
throw new Error('Failed to delete live blog'); throw new Error('Failed to delete live blog');
} }
} }
export async function archiveLiveBlog(id: string): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/archive`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to archive live blog');
}
return response.json();
}
export async function publishLiveBlog(id: string, status: 'draft' | 'live' | 'ended' = 'draft'): Promise<LiveBlog> {
const response = await fetch(`${API_BASE_URL}/live-blogs/${id}/publish?status=${status}`, {
method: 'PATCH',
});
if (!response.ok) {
throw new Error('Failed to publish live blog');
}
return response.json();
}
+157
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 };
}
+66 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api'; import * as api from '../lib/api';
export function useArticles(params: api.FindArticlesParams = {}) { export function useArticles(params: api.FindArticlesParams = {}) {
@@ -23,3 +23,68 @@ export function useArticleById(id: string) {
enabled: !!id, enabled: !!id,
}); });
} }
export function useCreateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createArticle,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
}
export function useUpdateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: api.UpdateArticleDto }) =>
api.updateArticle(id, dto),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
},
});
}
export function useDeleteArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteArticle,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
// Also invalidate any specific article queries
queryClient.removeQueries({ queryKey: ['article', variables] });
},
});
}
export function useArchiveArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.archiveArticle,
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['article', variables] });
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
},
});
}
export function usePublishArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status = 'published' }: { id: string; status?: 'draft' | 'published' }) =>
api.publishArticle(id, status),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['article', variables.id] });
queryClient.invalidateQueries({ queryKey: ['article', data.slug] });
},
});
}
+27
View File
@@ -156,3 +156,30 @@ export function useDeleteLiveBlog() {
}, },
}); });
} }
export function useArchiveLiveBlog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.archiveLiveBlog(id),
onSuccess: () => {
// Invalidate all live blogs queries
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
},
});
}
export function usePublishLiveBlog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status = 'draft' }: { id: string; status?: 'draft' | 'live' | 'ended' }) =>
api.publishLiveBlog(id, status),
onSuccess: () => {
// Invalidate all live blogs queries
queryClient.invalidateQueries({ queryKey: ['liveBlogs'] });
queryClient.invalidateQueries({ queryKey: ['recentLiveBlogs'] });
},
});
}
+1525 -1234
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
1. role based auth [admin, contributor, user]
2. [ ] video
3. social media integration [telegram, whatsup, facebook, viber]
4. share with functionality
lets implement a role based auth
admin can create, edit and delete articles and liveblogs
admin can create contributors
contributors can create, edit and delete articles and liveblogs
user can react(like, dislike) to articles and write comments