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 { NotFoundException } from '@nestjs/common';
|
||||||
import type { Repository } from 'typeorm';
|
import type { Repository } from 'typeorm';
|
||||||
import { resolveBlogImagePath } from '../../../config/uploadPaths';
|
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
import { BlogPost } from '../entities/BlogPost';
|
import { BlogPost } from '../entities/BlogPost';
|
||||||
import { BlogPostsService } from './BlogPostsService';
|
import { BlogPostsService } from './BlogPostsService';
|
||||||
@@ -58,7 +58,28 @@ describe('BlogPostsService', () => {
|
|||||||
id: 'post-1',
|
id: 'post-1',
|
||||||
title: '',
|
title: '',
|
||||||
contentHtml: '',
|
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 { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { resolveBlogImagePath } from '../../../config/uploadPaths';
|
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||||
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
|
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
|
||||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
||||||
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
||||||
import { BlogPost } from '../entities/BlogPost';
|
import { BlogPost } from '../entities/BlogPost';
|
||||||
|
import type { BlogPostExtended } from '../types/BlogPostExtended';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BlogPostsService {
|
export class BlogPostsService {
|
||||||
@@ -15,17 +16,19 @@ export class BlogPostsService {
|
|||||||
private readonly blogPostRepo: Repository<BlogPost>
|
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({
|
const [items, total] = await this.blogPostRepo.findAndCount({
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: 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({
|
const entity = this.blogPostRepo.create({
|
||||||
title: '',
|
title: '',
|
||||||
contentHtml: '',
|
contentHtml: '',
|
||||||
@@ -37,17 +40,13 @@ export class BlogPostsService {
|
|||||||
return this.findOne(entity.id);
|
return this.findOne(entity.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string): Promise<BlogPost> {
|
async findOne(id: string): Promise<BlogPostExtended> {
|
||||||
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
const entity = await this.findEntity(id);
|
||||||
|
|
||||||
if (!entity) {
|
return this.toPostExtended(entity);
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entity;
|
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPost> {
|
|
||||||
const exists = await this.blogPostRepo.exists({ where: { id } });
|
const exists = await this.blogPostRepo.exists({ where: { id } });
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -63,8 +62,8 @@ export class BlogPostsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadThumbnail(id: string, filename: string): Promise<BlogPost> {
|
async uploadThumbnail(id: string, filename: string): Promise<BlogPostExtended> {
|
||||||
const post = await this.findOne(id);
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
const previousStorageKey = post.thumbnailStorageKey;
|
const previousStorageKey = post.thumbnailStorageKey;
|
||||||
|
|
||||||
@@ -74,8 +73,8 @@ export class BlogPostsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadBanner(id: string, filename: string): Promise<BlogPost> {
|
async uploadBanner(id: string, filename: string): Promise<BlogPostExtended> {
|
||||||
const post = await this.findOne(id);
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
const previousStorageKey = post.bannerStorageKey;
|
const previousStorageKey = post.bannerStorageKey;
|
||||||
|
|
||||||
@@ -86,7 +85,7 @@ export class BlogPostsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
const post = await this.findOne(id);
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
await this.blogPostRepo.delete(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> {
|
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
|
||||||
if (!storageKey) {
|
if (!storageKey) {
|
||||||
return;
|
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;
|
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 {
|
.sf-blog-card__image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: var(--sf-radius-sm);
|
|
||||||
background: var(--sf-surface-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sf-blog-card__title {
|
.sf-blog-card__title {
|
||||||
@@ -1639,10 +1644,10 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sf-blog-detail__banner {
|
.sf-blog-detail__banner {
|
||||||
width: 100vw;
|
|
||||||
margin-left: calc(50% - 50vw);
|
|
||||||
margin-bottom: var(--sf-space-4);
|
margin-bottom: var(--sf-space-4);
|
||||||
background: var(--sf-surface-muted);
|
background: var(--sf-surface-muted);
|
||||||
|
border-radius: var(--sf-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sf-blog-detail__banner-image {
|
.sf-blog-detail__banner-image {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<li class='sf-card sf-blog-card'>
|
<li class='sf-card sf-blog-card'>
|
||||||
<a class='sf-blog-card__link' href='{{detailHref}}'>
|
<a class='sf-blog-card__link' href='{{detailHref}}'>
|
||||||
{{#if thumbnailUrl}}
|
{{#if thumbnailUrl}}
|
||||||
|
<div class='sf-blog-card__media'>
|
||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
class='sf-blog-card__image'
|
class='sf-blog-card__image'
|
||||||
src=thumbnailUrl
|
src=thumbnailUrl
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
height='1'
|
height='1'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
}}
|
}}
|
||||||
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<strong class='sf-blog-card__title'>{{title}}</strong>
|
<strong class='sf-blog-card__title'>{{title}}</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user