Slave/blog #9
@@ -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<BlogPost> {
|
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
|
||||||
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,13 +1,15 @@
|
|||||||
<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}}
|
||||||
{{> storefront-image
|
<div class='sf-blog-card__media'>
|
||||||
class='sf-blog-card__image'
|
{{> storefront-image
|
||||||
src=thumbnailUrl
|
class='sf-blog-card__image'
|
||||||
width='1'
|
src=thumbnailUrl
|
||||||
height='1'
|
width='1'
|
||||||
loading='lazy'
|
height='1'
|
||||||
}}
|
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