Slave/blog #9
@@ -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