diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index cac9cce..ecf09e9 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -6,6 +6,7 @@ declare global { interface Locals { postListBloc: import('$lib/post/adapter/presenter/postListBloc').PostListBloc; + postBloc: import('$lib/post/adapter/presenter/postBloc').PostBloc; } // interface PageData {} diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index b2b4de9..6c263ce 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,13 +1,19 @@ 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 { 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 type { Handle } from '@sveltejs/kit'; export const handle: Handle = ({ event, resolve }) => { const postApiService = new PostApiServiceImpl(event.fetch); 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.postBloc = new PostBloc(getPostUseCase); + return resolve(event); }; diff --git a/frontend/src/lib/post/adapter/gateway/postApiService.ts b/frontend/src/lib/post/adapter/gateway/postApiService.ts index 123480e..ccabb11 100644 --- a/frontend/src/lib/post/adapter/gateway/postApiService.ts +++ b/frontend/src/lib/post/adapter/gateway/postApiService.ts @@ -1,5 +1,7 @@ import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; +import type { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto'; export interface PostApiService { getAllPosts(): Promise; + getPost(id: number): Promise; } diff --git a/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts b/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts index 560f82b..bfd0f3e 100644 --- a/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts +++ b/frontend/src/lib/post/adapter/gateway/postRepositoryImpl.ts @@ -1,5 +1,6 @@ import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; 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'; export class PostRepositoryImpl implements PostRepository { @@ -9,4 +10,9 @@ export class PostRepositoryImpl implements PostRepository { const dtos = await this.postApiService.getAllPosts(); return dtos.map((dto) => dto.toEntity()); } + + async getPost(id: number): Promise { + const dto = await this.postApiService.getPost(id); + return dto?.toEntity() ?? null; + } } diff --git a/frontend/src/lib/post/adapter/gateway/postResponseDto.ts b/frontend/src/lib/post/adapter/gateway/postResponseDto.ts new file mode 100644 index 0000000..00f443d --- /dev/null +++ b/frontend/src/lib/post/adapter/gateway/postResponseDto.ts @@ -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 + }); + } +} diff --git a/frontend/src/lib/post/adapter/presenter/postBloc.ts b/frontend/src/lib/post/adapter/presenter/postBloc.ts new file mode 100644 index 0000000..0e65a29 --- /dev/null +++ b/frontend/src/lib/post/adapter/presenter/postBloc.ts @@ -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; +export type PostEvent = PostLoadedEvent; + +export class PostBloc { + private readonly state = writable({ + 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 { + switch (event.event) { + case PostEventType.PostLoadedEvent: + return this.loadPost(event.id); + } + } + + private async loadPost(id: number): Promise { + 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; +} diff --git a/frontend/src/lib/post/adapter/presenter/postListBloc.ts b/frontend/src/lib/post/adapter/presenter/postListBloc.ts index 11829fe..e481e4d 100644 --- a/frontend/src/lib/post/adapter/presenter/postListBloc.ts +++ b/frontend/src/lib/post/adapter/presenter/postListBloc.ts @@ -1,6 +1,6 @@ import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState'; 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'; export type PostListState = AsyncState; @@ -12,7 +12,7 @@ export class PostListBloc { }); constructor( - private readonly getAllPostsUseCase: GetAllPostUseCase, + private readonly getAllPostsUseCase: GetAllPostsUseCase, initialData?: readonly PostInfoViewModel[] ) { this.state.set({ diff --git a/frontend/src/lib/post/adapter/presenter/postViewModel.ts b/frontend/src/lib/post/adapter/presenter/postViewModel.ts new file mode 100644 index 0000000..24bfb31 --- /dev/null +++ b/frontend/src/lib/post/adapter/presenter/postViewModel.ts @@ -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; +} diff --git a/frontend/src/lib/post/application/repository/postRepository.ts b/frontend/src/lib/post/application/repository/postRepository.ts index 7afe74a..021170f 100644 --- a/frontend/src/lib/post/application/repository/postRepository.ts +++ b/frontend/src/lib/post/application/repository/postRepository.ts @@ -1,5 +1,7 @@ +import type { Post } from '$lib/post/domain/entity/post'; import type { PostInfo } from '$lib/post/domain/entity/postInfo'; export interface PostRepository { getAllPosts(): Promise; + getPost(id: number): Promise; } diff --git a/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts b/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts index d2eb96e..f55957c 100644 --- a/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts +++ b/frontend/src/lib/post/application/useCase/getAllPostsUseCase.ts @@ -1,7 +1,7 @@ import type { PostRepository } from '$lib/post/application/repository/postRepository'; import type { PostInfo } from '$lib/post/domain/entity/postInfo'; -export class GetAllPostUseCase { +export class GetAllPostsUseCase { constructor(private readonly postRepository: PostRepository) {} execute(): Promise { diff --git a/frontend/src/lib/post/application/useCase/getPostUseCase.ts b/frontend/src/lib/post/application/useCase/getPostUseCase.ts new file mode 100644 index 0000000..2e2d920 --- /dev/null +++ b/frontend/src/lib/post/application/useCase/getPostUseCase.ts @@ -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 { + return this.postRepository.getPost(id); + } +} diff --git a/frontend/src/lib/post/domain/entity/post.ts b/frontend/src/lib/post/domain/entity/post.ts new file mode 100644 index 0000000..458a4de --- /dev/null +++ b/frontend/src/lib/post/domain/entity/post.ts @@ -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; + } +} diff --git a/frontend/src/lib/post/framework/api/postApiServiceImpl.ts b/frontend/src/lib/post/framework/api/postApiServiceImpl.ts index 93091a2..61e1328 100644 --- a/frontend/src/lib/post/framework/api/postApiServiceImpl.ts +++ b/frontend/src/lib/post/framework/api/postApiServiceImpl.ts @@ -1,6 +1,7 @@ import { Environment } from '$lib/environment'; import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; +import { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto'; export class PostApiServiceImpl implements PostApiService { constructor(private fetchFn: typeof fetch) {} @@ -17,4 +18,17 @@ export class PostApiServiceImpl implements PostApiService { const json = await response.json(); return json.map(PostInfoResponseDto.fromJson); } + + async getPost(id: number): Promise { + 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); + } } diff --git a/frontend/src/lib/post/framework/ui/Label.svelte b/frontend/src/lib/post/framework/ui/Label.svelte index 58763c6..1962dd8 100644 --- a/frontend/src/lib/post/framework/ui/Label.svelte +++ b/frontend/src/lib/post/framework/ui/Label.svelte @@ -9,5 +9,5 @@ style="background-color: {label.color.hex};" >
- {label.name} + {label.name} diff --git a/frontend/src/lib/post/framework/ui/PostContentHeader.svelte b/frontend/src/lib/post/framework/ui/PostContentHeader.svelte new file mode 100644 index 0000000..5f5d503 --- /dev/null +++ b/frontend/src/lib/post/framework/ui/PostContentHeader.svelte @@ -0,0 +1,17 @@ + + +
+
+ {#each postInfo.labels as label (label.id)} +
+

{postInfo.title}

+ {postInfo.description} + {postInfo.formattedPublishedTime} +
diff --git a/frontend/src/lib/post/framework/ui/PostContentPage.svelte b/frontend/src/lib/post/framework/ui/PostContentPage.svelte index f428674..1c94d80 100644 --- a/frontend/src/lib/post/framework/ui/PostContentPage.svelte +++ b/frontend/src/lib/post/framework/ui/PostContentPage.svelte @@ -1,6 +1,18 @@ +
+ {#if state.data} + + {/if} +
diff --git a/frontend/src/lib/post/framework/ui/PostOverallPage.svelte b/frontend/src/lib/post/framework/ui/PostOverallPage.svelte index 19cdd4d..266c7cf 100644 --- a/frontend/src/lib/post/framework/ui/PostOverallPage.svelte +++ b/frontend/src/lib/post/framework/ui/PostOverallPage.svelte @@ -11,7 +11,7 @@
-
文章
+

文章

{#each state.data ?? [] as postInfo (postInfo.id)} diff --git a/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte b/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte index 470271e..67fa3b0 100644 --- a/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte +++ b/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte @@ -5,12 +5,12 @@ const { labels }: { labels: readonly LabelViewModel[] } = $props(); -
+
{#each labels.slice(0, 2) as label (label.id)}