BLOG-45 Post content page #67

Merged
squid merged 9 commits from BLOG-45_post_content_page into main 2025-07-24 22:20:58 +08:00
23 changed files with 296 additions and 23 deletions
Showing only changes of commit 65a14e5d2c - Show all commits

View File

@ -6,6 +6,7 @@ declare global {
interface Locals { interface Locals {
postListBloc: import('$lib/post/adapter/presenter/postListBloc').PostListBloc; postListBloc: import('$lib/post/adapter/presenter/postListBloc').PostListBloc;
postBloc: import('$lib/post/adapter/presenter/postBloc').PostBloc;
} }
// interface PageData {} // interface PageData {}

View File

@ -1,13 +1,19 @@
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl'; import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl';
import { PostBloc } from '$lib/post/adapter/presenter/postBloc';
import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc'; import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc';
import { GetAllPostUseCase } from '$lib/post/application/useCase/getAllPostsUseCase'; import { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl'; import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
export const handle: Handle = ({ event, resolve }) => { export const handle: Handle = ({ event, resolve }) => {
const postApiService = new PostApiServiceImpl(event.fetch); const postApiService = new PostApiServiceImpl(event.fetch);
const postRepository = new PostRepositoryImpl(postApiService); const postRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostUseCase(postRepository); const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const getPostUseCase = new GetPostUseCase(postRepository);
event.locals.postListBloc = new PostListBloc(getAllPostsUseCase); event.locals.postListBloc = new PostListBloc(getAllPostsUseCase);
event.locals.postBloc = new PostBloc(getPostUseCase);
return resolve(event); return resolve(event);
}; };

View File

@ -1,5 +1,7 @@
import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
import type { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export interface PostApiService { export interface PostApiService {
getAllPosts(): Promise<PostInfoResponseDto[]>; getAllPosts(): Promise<PostInfoResponseDto[]>;
getPost(id: number): Promise<PostResponseDto | null>;
} }

View File

@ -1,5 +1,6 @@
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/repository/postRepository'; import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class PostRepositoryImpl implements PostRepository { export class PostRepositoryImpl implements PostRepository {
@ -9,4 +10,9 @@ export class PostRepositoryImpl implements PostRepository {
const dtos = await this.postApiService.getAllPosts(); const dtos = await this.postApiService.getAllPosts();
return dtos.map((dto) => dto.toEntity()); return dtos.map((dto) => dto.toEntity());
} }
async getPost(id: number): Promise<Post | null> {
const dto = await this.postApiService.getPost(id);
return dto?.toEntity() ?? null;
}
} }

View File

@ -0,0 +1,41 @@
import {
PostInfoResponseDto,
PostInfoResponseSchema
} from '$lib/post/adapter/gateway/postInfoResponseDto';
import { Post } from '$lib/post/domain/entity/post';
import z from 'zod';
export const PostResponseSchema = z.object({
id: z.int32(),
info: PostInfoResponseSchema,
content: z.string()
});
export class PostResponseDto {
readonly id: number;
readonly info: PostInfoResponseDto;
readonly content: string;
private constructor(props: { id: number; info: PostInfoResponseDto; content: string }) {
this.id = props.id;
this.info = props.info;
this.content = props.content;
}
static fromJson(json: unknown): PostResponseDto {
const parsedJson = PostResponseSchema.parse(json);
return new PostResponseDto({
id: parsedJson.id,
info: PostInfoResponseDto.fromJson(parsedJson.info),
content: parsedJson.content
});
}
toEntity(): Post {
return new Post({
id: this.id,
info: this.info.toEntity(),
content: this.content
});
}
}

View File

@ -0,0 +1,62 @@
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
import { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
import type { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { get, writable } from 'svelte/store';
export type PostState = AsyncState<PostViewModel>;
export type PostEvent = PostLoadedEvent;
export class PostBloc {
private readonly state = writable<PostState>({
status: StatusType.Idle
});
constructor(
private readonly getPostUseCase: GetPostUseCase,
initialData?: PostViewModel
) {
this.state.set({
status: StatusType.Idle,
data: initialData
});
}
get subscribe() {
return this.state.subscribe;
}
async dispatch(event: PostEvent): Promise<PostState> {
switch (event.event) {
case PostEventType.PostLoadedEvent:
return this.loadPost(event.id);
}
}
private async loadPost(id: number): Promise<PostState> {
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
const post = await this.getPostUseCase.execute(id);
if (!post) {
this.state.set({ status: StatusType.Error, error: new Error('Post not found') });
return get(this.state);
}
const postViewModel = PostViewModel.fromEntity(post);
const result: PostState = {
status: StatusType.Success,
data: postViewModel
};
this.state.set(result);
return result;
}
}
export enum PostEventType {
PostLoadedEvent
}
export interface PostLoadedEvent {
event: PostEventType.PostLoadedEvent;
id: number;
}

View File

@ -1,6 +1,6 @@
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState'; import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel'; import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import type { GetAllPostUseCase } from '$lib/post/application/useCase/getAllPostsUseCase'; import type { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
export type PostListState = AsyncState<readonly PostInfoViewModel[]>; export type PostListState = AsyncState<readonly PostInfoViewModel[]>;
@ -12,7 +12,7 @@ export class PostListBloc {
}); });
constructor( constructor(
private readonly getAllPostsUseCase: GetAllPostUseCase, private readonly getAllPostsUseCase: GetAllPostsUseCase,
initialData?: readonly PostInfoViewModel[] initialData?: readonly PostInfoViewModel[]
) { ) {
this.state.set({ this.state.set({

View File

@ -0,0 +1,47 @@
import {
PostInfoViewModel,
type DehydratedPostInfoProps
} from '$lib/post/adapter/presenter/postInfoViewModel';
import type { Post } from '$lib/post/domain/entity/post';
export class PostViewModel {
id: number;
info: PostInfoViewModel;
content: string;
private constructor(props: { id: number; info: PostInfoViewModel; content: string }) {
this.id = props.id;
this.info = props.info;
this.content = props.content;
}
static fromEntity(post: Post): PostViewModel {
return new PostViewModel({
id: post.id,
info: PostInfoViewModel.fromEntity(post.info),
content: post.content
});
}
static rehydrate(props: DehydratedPostProps): PostViewModel {
return new PostViewModel({
id: props.id,
info: PostInfoViewModel.rehydrate(props.info),
content: props.content
});
}
dehydrate(): DehydratedPostProps {
return {
id: this.id,
info: this.info.dehydrate(),
content: this.content
};
}
}
export interface DehydratedPostProps {
id: number;
info: DehydratedPostInfoProps;
content: string;
}

View File

@ -1,5 +1,7 @@
import type { Post } from '$lib/post/domain/entity/post';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export interface PostRepository { export interface PostRepository {
getAllPosts(): Promise<PostInfo[]>; getAllPosts(): Promise<PostInfo[]>;
getPost(id: number): Promise<Post | null>;
} }

View File

@ -1,7 +1,7 @@
import type { PostRepository } from '$lib/post/application/repository/postRepository'; import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class GetAllPostUseCase { export class GetAllPostsUseCase {
constructor(private readonly postRepository: PostRepository) {} constructor(private readonly postRepository: PostRepository) {}
execute(): Promise<PostInfo[]> { execute(): Promise<PostInfo[]> {

View File

@ -0,0 +1,10 @@
import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
export class GetPostUseCase {
constructor(private readonly postRepository: PostRepository) {}
execute(id: number): Promise<Post | null> {
return this.postRepository.getPost(id);
}
}

View File

@ -0,0 +1,13 @@
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class Post {
id: number;
info: PostInfo;
content: string;
constructor(props: { id: number; info: PostInfo; content: string }) {
this.id = props.id;
this.info = props.info;
this.content = props.content;
}
}

View File

@ -1,6 +1,7 @@
import { Environment } from '$lib/environment'; import { Environment } from '$lib/environment';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
import { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export class PostApiServiceImpl implements PostApiService { export class PostApiServiceImpl implements PostApiService {
constructor(private fetchFn: typeof fetch) {} constructor(private fetchFn: typeof fetch) {}
@ -17,4 +18,17 @@ export class PostApiServiceImpl implements PostApiService {
const json = await response.json(); const json = await response.json();
return json.map(PostInfoResponseDto.fromJson); return json.map(PostInfoResponseDto.fromJson);
} }
async getPost(id: number): Promise<PostResponseDto | null> {
const url = new URL(`post/${id}`, Environment.API_BASE_URL);
const response = await this.fetchFn(url.href);
if (!response.ok) {
return null;
}
const json = await response.json();
return PostResponseDto.fromJson(json);
}
} }

View File

@ -9,5 +9,5 @@
style="background-color: {label.color.hex};" style="background-color: {label.color.hex};"
> >
<div class="size-2 rounded-full" style="background-color: {label.color.darken(0.2).hex};"></div> <div class="size-2 rounded-full" style="background-color: {label.color.darken(0.2).hex};"></div>
<span>{label.name}</span> <span class="text-xs">{label.name}</span>
</div> </div>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import type { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import Label from '$lib/post/framework/ui/Label.svelte';
const { postInfo }: { postInfo: PostInfoViewModel } = $props();
</script>
<div class="flex flex-col gap-y-4 py-9">
<div class="flex flex-row gap-2">
{#each postInfo.labels as label (label.id)}
<Label {label} />
{/each}
</div>
<h1 class="text-3xl font-bold">{postInfo.title}</h1>
<span>{postInfo.description}</span>
<span class="text-gray-500">{postInfo.formattedPublishedTime}</span>
</div>

View File

@ -1,6 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { PostInfoViewModel } from "$lib/post/adapter/presenter/postInfoViewModel"; import { PostBloc, PostEventType } from '$lib/post/adapter/presenter/postBloc';
import PostContentHeader from '$lib/post/framework/ui/PostContentHeader.svelte';
import { getContext, onMount } from 'svelte';
const { post }: { post: PostInfoViewModel } = $props(); const { id }: { id: number } = $props();
const postBloc = getContext<PostBloc>(PostBloc.name);
const state = $derived($postBloc);
onMount(() => postBloc.dispatch({ event: PostEventType.PostLoadedEvent, id: id }));
</script> </script>
<div class="container pb-10">
{#if state.data}
<PostContentHeader postInfo={state.data.info} />
{/if}
</div>

View File

@ -11,7 +11,7 @@
</script> </script>
<div class="container pb-10"> <div class="container pb-10">
<div class="py-9 text-center text-3xl font-bold text-gray-800 md:py-20 md:text-5xl">文章</div> <h1 class="py-9 text-center text-3xl font-bold text-gray-800 md:py-20 md:text-5xl">文章</h1>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-y-8 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-y-8 lg:grid-cols-3">
{#each state.data ?? [] as postInfo (postInfo.id)} {#each state.data ?? [] as postInfo (postInfo.id)}
<PostPreview {postInfo} /> <PostPreview {postInfo} />

View File

@ -5,12 +5,12 @@
const { labels }: { labels: readonly LabelViewModel[] } = $props(); const { labels }: { labels: readonly LabelViewModel[] } = $props();
</script> </script>
<div class="flex flex-row gap-x-2 text-xs"> <div class="flex flex-row gap-x-2">
{#each labels.slice(0, 2) as label (label.id)} {#each labels.slice(0, 2) as label (label.id)}
<Label {label} /> <Label {label} />
{/each} {/each}
{#if labels.length > 2} {#if labels.length > 2}
<div class="rounded-full bg-gray-200 px-2 py-0.5"> <div class="rounded-full bg-gray-200 px-2 py-0.5 text-xs">
<span>+{labels.length - 2}</span> <span>+{labels.length - 2}</span>
</div> </div>
{/if} {/if}

View File

@ -1,5 +1,9 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<div <div
class="mx-auto flex min-h-content-height max-w-screen-xl flex-col items-center justify-center px-4 md:px-6" class="min-h-content-height mx-auto flex max-w-screen-xl flex-col items-center justify-center px-4 md:px-6"
> >
<div class="flex flex-row items-end gap-x-4 md:gap-x-6"> <div class="flex flex-row items-end gap-x-4 md:gap-x-6">
<h1 class="text-5xl font-extrabold text-gray-800 underline md:text-7xl">404</h1> <h1 class="text-5xl font-extrabold text-gray-800 underline md:text-7xl">404</h1>
@ -12,3 +16,4 @@
</h2> </h2>
</div> </div>
</div> </div>
<div>{page.error?.message}</div>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl'; import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl';
import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc'; import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc';
import { GetAllPostUseCase } from '$lib/post/application/useCase/getAllPostsUseCase'; import { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl'; import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
import { setContext } from 'svelte'; import { setContext } from 'svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@ -14,7 +14,7 @@
const postApiService = new PostApiServiceImpl(fetch); const postApiService = new PostApiServiceImpl(fetch);
const postRepository = new PostRepositoryImpl(postApiService); const postRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostUseCase(postRepository); const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const postListBloc = new PostListBloc(getAllPostsUseCase, initialData); const postListBloc = new PostListBloc(getAllPostsUseCase, initialData);
setContext(PostListBloc.name, postListBloc); setContext(PostListBloc.name, postListBloc);

View File

@ -0,0 +1,24 @@
import { PostEventType } from '$lib/post/adapter/presenter/postBloc';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
const { postBloc } = locals;
const id = parseInt(params.id, 10);
if (isNaN(id) || id <= 0) {
error(400, { message: 'Invalid post ID' });
}
const state = await postBloc.dispatch({
event: PostEventType.PostLoadedEvent,
id: id
});
if (!state.data) {
error(404, { message: 'Post not found' });
}
return {
dehydratedData: state.data.dehydrate()
};
};

View File

@ -1,7 +1,25 @@
<script lang="ts"> <script lang="ts">
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl';
import { PostBloc } from '$lib/post/adapter/presenter/postBloc';
import { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
import { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
import { setContext } from 'svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import PostContentPage from '$lib/post/framework/ui/PostContentPage.svelte';
const { data }: PageProps = $props(); const { data, params }: PageProps = $props();
const id = parseInt(params.id, 10);
const initialData = PostViewModel.rehydrate(data.dehydratedData!);
const postApiService = new PostApiServiceImpl(fetch);
const postRepository = new PostRepositoryImpl(postApiService);
const getPostUseCase = new GetPostUseCase(postRepository);
const postBloc = new PostBloc(getPostUseCase, initialData);
setContext(PostBloc.name, postBloc);
</script> </script>
<div>{data.id}</div> <PostContentPage {id} />

View File

@ -1,7 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return {
id: params.id,
}
};