Slave/blog #9

Merged
nobswebdev merged 14 commits from slave/blog into master 2026-09-16 20:15:08 +00:00
5 changed files with 82 additions and 31 deletions
Showing only changes of commit 9cd7059928 - Show all commits
@@ -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>