BLOG-45 Post content page #67
1
frontend/src/app.d.ts
vendored
1
frontend/src/app.d.ts
vendored
@ -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 {}
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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<PostInfoResponseDto[]>;
|
||||
getPost(id: number): Promise<PostResponseDto | null>;
|
||||
}
|
||||
|
@ -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<Post | null> {
|
||||
const dto = await this.postApiService.getPost(id);
|
||||
return dto?.toEntity() ?? null;
|
||||
}
|
||||
}
|
||||
|
41
frontend/src/lib/post/adapter/gateway/postResponseDto.ts
Normal file
41
frontend/src/lib/post/adapter/gateway/postResponseDto.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
62
frontend/src/lib/post/adapter/presenter/postBloc.ts
Normal file
62
frontend/src/lib/post/adapter/presenter/postBloc.ts
Normal 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;
|
||||
}
|
@ -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<readonly PostInfoViewModel[]>;
|
||||
@ -12,7 +12,7 @@ export class PostListBloc {
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly getAllPostsUseCase: GetAllPostUseCase,
|
||||
private readonly getAllPostsUseCase: GetAllPostsUseCase,
|
||||
initialData?: readonly PostInfoViewModel[]
|
||||
) {
|
||||
this.state.set({
|
||||
|
47
frontend/src/lib/post/adapter/presenter/postViewModel.ts
Normal file
47
frontend/src/lib/post/adapter/presenter/postViewModel.ts
Normal 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;
|
||||
}
|
@ -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<PostInfo[]>;
|
||||
getPost(id: number): Promise<Post | null>;
|
||||
}
|
||||
|
@ -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<PostInfo[]> {
|
||||
|
10
frontend/src/lib/post/application/useCase/getPostUseCase.ts
Normal file
10
frontend/src/lib/post/application/useCase/getPostUseCase.ts
Normal 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);
|
||||
}
|
||||
}
|
13
frontend/src/lib/post/domain/entity/post.ts
Normal file
13
frontend/src/lib/post/domain/entity/post.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
@ -9,5 +9,5 @@
|
||||
style="background-color: {label.color.hex};"
|
||||
>
|
||||
<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>
|
||||
|
17
frontend/src/lib/post/framework/ui/PostContentHeader.svelte
Normal file
17
frontend/src/lib/post/framework/ui/PostContentHeader.svelte
Normal 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>
|
@ -1,6 +1,18 @@
|
||||
<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>
|
||||
|
||||
<div class="container pb-10">
|
||||
{#if state.data}
|
||||
<PostContentHeader postInfo={state.data.info} />
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
{#each state.data ?? [] as postInfo (postInfo.id)}
|
||||
<PostPreview {postInfo} />
|
||||
|
@ -5,12 +5,12 @@
|
||||
const { labels }: { labels: readonly LabelViewModel[] } = $props();
|
||||
</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)}
|
||||
<Label {label} />
|
||||
{/each}
|
||||
{#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>
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<h1 class="text-5xl font-extrabold text-gray-800 underline md:text-7xl">404</h1>
|
||||
@ -12,3 +16,4 @@
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div>{page.error?.message}</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl';
|
||||
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 { setContext } from 'svelte';
|
||||
import type { PageProps } from './$types';
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
const postApiService = new PostApiServiceImpl(fetch);
|
||||
const postRepository = new PostRepositoryImpl(postApiService);
|
||||
const getAllPostsUseCase = new GetAllPostUseCase(postRepository);
|
||||
const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
|
||||
const postListBloc = new PostListBloc(getAllPostsUseCase, initialData);
|
||||
|
||||
setContext(PostListBloc.name, postListBloc);
|
||||
|
24
frontend/src/routes/post/[id]/+page.server.ts
Normal file
24
frontend/src/routes/post/[id]/+page.server.ts
Normal 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()
|
||||
};
|
||||
};
|
@ -1,7 +1,25 @@
|
||||
<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 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>
|
||||
|
||||
<div>{data.id}</div>
|
||||
<PostContentPage {id} />
|
||||
|
@ -1,7 +0,0 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
return {
|
||||
id: params.id,
|
||||
}
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user