diff --git a/backend/src/modules/blog/services/BlogPostsService.spec.ts b/backend/src/modules/blog/services/BlogPostsService.spec.ts index 64d4e5f..ad16f29 100644 --- a/backend/src/modules/blog/services/BlogPostsService.spec.ts +++ b/backend/src/modules/blog/services/BlogPostsService.spec.ts @@ -1,6 +1,6 @@ import { NotFoundException } from '@nestjs/common'; import type { Repository } from 'typeorm'; -import { resolveBlogImagePath } from '../../../config/uploadPaths'; +import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths'; import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; import { BlogPost } from '../entities/BlogPost'; import { BlogPostsService } from './BlogPostsService'; @@ -58,7 +58,28 @@ describe('BlogPostsService', () => { id: 'post-1', title: '', contentHtml: '', - isDraft: true + isDraft: true, + thumbnailUrl: null, + bannerUrl: null + }) + ); + }); + + it('maps image storage keys to public urls', async () => { + blogPostRepo.findOne.mockResolvedValue({ + id: 'post-1', + title: 'Hello', + thumbnailStorageKey: 'thumb.webp', + bannerStorageKey: 'banner.webp', + contentHtml: '

Body

', + isDraft: false + }); + + await expect(service.findOne('post-1')).resolves.toEqual( + expect.objectContaining({ + title: 'Hello', + thumbnailUrl: getBlogImagePublicUrl('thumb.webp'), + bannerUrl: getBlogImagePublicUrl('banner.webp') }) ); }); diff --git a/backend/src/modules/blog/services/BlogPostsService.ts b/backend/src/modules/blog/services/BlogPostsService.ts index 72a24ea..042d6b7 100644 --- a/backend/src/modules/blog/services/BlogPostsService.ts +++ b/backend/src/modules/blog/services/BlogPostsService.ts @@ -1,12 +1,13 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { resolveBlogImagePath } from '../../../config/uploadPaths'; +import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths'; import type { PaginatedResponse } from '../../../types/PaginatedResponse'; import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto'; import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto'; import { BlogPost } from '../entities/BlogPost'; +import type { BlogPostExtended } from '../types/BlogPostExtended'; @Injectable() export class BlogPostsService { @@ -15,17 +16,19 @@ export class BlogPostsService { private readonly blogPostRepo: Repository ) {} - async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise> { + async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise> { const [items, total] = await this.blogPostRepo.findAndCount({ order: { createdAt: 'DESC' }, skip: (page - 1) * limit, take: limit }); - return { items, total, page, limit }; + const itemsExtended = items.map(post => this.toPostExtended(post)); + + return { items: itemsExtended, total, page, limit }; } - async createDraft(): Promise { + async createDraft(): Promise { const entity = this.blogPostRepo.create({ title: '', contentHtml: '', @@ -37,17 +40,13 @@ export class BlogPostsService { return this.findOne(entity.id); } - async findOne(id: string): Promise { - const entity = await this.blogPostRepo.findOne({ where: { id } }); + async findOne(id: string): Promise { + const entity = await this.findEntity(id); - if (!entity) { - throw new NotFoundException(); - } - - return entity; + return this.toPostExtended(entity); } - async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise { + async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise { const exists = await this.blogPostRepo.exists({ where: { id } }); if (!exists) { @@ -63,8 +62,8 @@ export class BlogPostsService { return this.findOne(id); } - async uploadThumbnail(id: string, filename: string): Promise { - const post = await this.findOne(id); + async uploadThumbnail(id: string, filename: string): Promise { + const post = await this.findEntity(id); const previousStorageKey = post.thumbnailStorageKey; @@ -74,8 +73,8 @@ export class BlogPostsService { return this.findOne(id); } - async uploadBanner(id: string, filename: string): Promise { - const post = await this.findOne(id); + async uploadBanner(id: string, filename: string): Promise { + const post = await this.findEntity(id); const previousStorageKey = post.bannerStorageKey; @@ -86,7 +85,7 @@ export class BlogPostsService { } async remove(id: string): Promise { - const post = await this.findOne(id); + const post = await this.findEntity(id); await this.blogPostRepo.delete(id); @@ -96,6 +95,24 @@ export class BlogPostsService { ]); } + private async findEntity(id: string): Promise { + const entity = await this.blogPostRepo.findOne({ where: { id } }); + + if (!entity) { + throw new NotFoundException(); + } + + return entity; + } + + private toPostExtended(post: BlogPost): BlogPostExtended { + return { + ...post, + thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null, + bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null + }; + } + private async removeBlogImageFile(storageKey: string | null): Promise { if (!storageKey) { return; diff --git a/backend/src/modules/blog/types/BlogPostExtended.ts b/backend/src/modules/blog/types/BlogPostExtended.ts new file mode 100644 index 0000000..1d765bb --- /dev/null +++ b/backend/src/modules/blog/types/BlogPostExtended.ts @@ -0,0 +1,6 @@ +import type { BlogPost } from '../entities/BlogPost'; + +export type BlogPostExtended = BlogPost & { + thumbnailUrl: string | null; + bannerUrl: string | null; +}; diff --git a/backend/src/modules/storefrontCore/public/css/storefront.css b/backend/src/modules/storefrontCore/public/css/storefront.css index 07b86b1..ad9ffff 100644 --- a/backend/src/modules/storefrontCore/public/css/storefront.css +++ b/backend/src/modules/storefrontCore/public/css/storefront.css @@ -1626,12 +1626,17 @@ a:hover { text-decoration: none; } +.sf-blog-card__media { + aspect-ratio: 16 / 9; + background: var(--sf-surface-muted); + border-radius: var(--sf-radius-sm); + overflow: hidden; +} + .sf-blog-card__image { width: 100%; - aspect-ratio: 16 / 9; + height: 100%; object-fit: cover; - border-radius: var(--sf-radius-sm); - background: var(--sf-surface-muted); } .sf-blog-card__title { @@ -1639,10 +1644,10 @@ a:hover { } .sf-blog-detail__banner { - width: 100vw; - margin-left: calc(50% - 50vw); margin-bottom: var(--sf-space-4); background: var(--sf-surface-muted); + border-radius: var(--sf-radius-sm); + overflow: hidden; } .sf-blog-detail__banner-image { diff --git a/backend/src/modules/storefrontCore/views/partials/blog-card.hbs b/backend/src/modules/storefrontCore/views/partials/blog-card.hbs index dfad2ea..205c41c 100644 --- a/backend/src/modules/storefrontCore/views/partials/blog-card.hbs +++ b/backend/src/modules/storefrontCore/views/partials/blog-card.hbs @@ -1,13 +1,15 @@
  • {{#if thumbnailUrl}} - {{> storefront-image - class='sf-blog-card__image' - src=thumbnailUrl - width='1' - height='1' - loading='lazy' - }} +
    + {{> storefront-image + class='sf-blog-card__image' + src=thumbnailUrl + width='1' + height='1' + loading='lazy' + }} +
    {{/if}} {{title}}