From 9cd70599286bf41b56d36a7890deb28fe97b9a45 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 16 Sep 2026 19:46:11 +0200 Subject: [PATCH] Expose blog image URLs on admin API and fix storefront card layout. Map storage keys to public paths in BlogPostsService responses so the CMS can resolve uploads consistently, and wrap blog card thumbnails in a fixed aspect-ratio container. --- .../blog/services/BlogPostsService.spec.ts | 25 ++++++++- .../modules/blog/services/BlogPostsService.ts | 51 ++++++++++++------- .../modules/blog/types/BlogPostExtended.ts | 6 +++ .../storefrontCore/public/css/storefront.css | 15 ++++-- .../views/partials/blog-card.hbs | 16 +++--- 5 files changed, 82 insertions(+), 31 deletions(-) create mode 100644 backend/src/modules/blog/types/BlogPostExtended.ts 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}}