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.
This commit is contained in:
@@ -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: '<p>Body</p>',
|
||||
isDraft: false
|
||||
});
|
||||
|
||||
await expect(service.findOne('post-1')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'Hello',
|
||||
thumbnailUrl: getBlogImagePublicUrl('thumb.webp'),
|
||||
bannerUrl: getBlogImagePublicUrl('banner.webp')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<BlogPost>
|
||||
) {}
|
||||
|
||||
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPost>> {
|
||||
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPostExtended>> {
|
||||
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<BlogPost> {
|
||||
async createDraft(): Promise<BlogPostExtended> {
|
||||
const entity = this.blogPostRepo.create({
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
@@ -37,17 +40,13 @@ export class BlogPostsService {
|
||||
return this.findOne(entity.id);
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<BlogPost> {
|
||||
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
||||
async findOne(id: string): Promise<BlogPostExtended> {
|
||||
const entity = await this.findEntity(id);
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException();
|
||||
return this.toPostExtended(entity);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPost> {
|
||||
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
|
||||
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<BlogPost> {
|
||||
const post = await this.findOne(id);
|
||||
async uploadThumbnail(id: string, filename: string): Promise<BlogPostExtended> {
|
||||
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<BlogPost> {
|
||||
const post = await this.findOne(id);
|
||||
async uploadBanner(id: string, filename: string): Promise<BlogPostExtended> {
|
||||
const post = await this.findEntity(id);
|
||||
|
||||
const previousStorageKey = post.bannerStorageKey;
|
||||
|
||||
@@ -86,7 +85,7 @@ export class BlogPostsService {
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
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<BlogPost> {
|
||||
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<void> {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { BlogPost } from '../entities/BlogPost';
|
||||
|
||||
export type BlogPostExtended = BlogPost & {
|
||||
thumbnailUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<li class='sf-card sf-blog-card'>
|
||||
<a class='sf-blog-card__link' href='{{detailHref}}'>
|
||||
{{#if thumbnailUrl}}
|
||||
<div class='sf-blog-card__media'>
|
||||
{{> storefront-image
|
||||
class='sf-blog-card__image'
|
||||
src=thumbnailUrl
|
||||
@@ -8,6 +9,7 @@
|
||||
height='1'
|
||||
loading='lazy'
|
||||
}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<strong class='sf-blog-card__title'>{{title}}</strong>
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user