BLOG-126 Post management (list and create) #139

Merged
squid merged 10 commits from BLOG-126_post_management into main 2025-10-15 04:21:15 +08:00
15 changed files with 305 additions and 27 deletions
Showing only changes of commit d569f35bc1 - Show all commits

View File

@ -17,6 +17,7 @@ import type { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoView
import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc';
import type { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import { CreatePostUseCase } from '$lib/post/application/useCase/createPostUseCase';
import { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
@ -43,7 +44,7 @@ export class Container {
}
createPostBloc(initialData?: PostViewModel): PostBloc {
return new PostBloc(this.useCases.getPostUseCase, initialData);
return new PostBloc(this.useCases.getPostUseCase, this.useCases.createPostUseCase, initialData);
}
}
@ -108,6 +109,7 @@ class UseCases {
private _uploadImageUseCase?: UploadImageUseCase;
private _getAllPostsUseCase?: GetAllPostsUseCase;
private _getPostUseCase?: GetPostUseCase;
private _createPostUseCase?: CreatePostUseCase;
constructor(repositories: Repositories) {
this.repositories = repositories;
@ -127,8 +129,14 @@ class UseCases {
this._getAllPostsUseCase ??= new GetAllPostsUseCase(this.repositories.postRepository);
return this._getAllPostsUseCase;
}
get getPostUseCase(): GetPostUseCase {
this._getPostUseCase ??= new GetPostUseCase(this.repositories.postRepository);
return this._getPostUseCase;
}
get createPostUseCase(): CreatePostUseCase {
this._createPostUseCase ??= new CreatePostUseCase(this.repositories.postRepository);
return this._createPostUseCase;
}
}

View File

@ -40,13 +40,9 @@
files = undefined;
fileInputErrorMessage = null;
}
function close() {
open = false;
}
</script>
<Dialog {open} onOpenChange={(val) => (open = val)}>
<Dialog bind:open>
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Upload</DialogTrigger>
<DialogContent
showCloseButton={false}
@ -76,7 +72,7 @@
</form>
<DialogFooter class="mt-6">
<Button variant="outline" onclick={close} {disabled}>Cancel</Button>
<Button variant="outline" onclick={() => (open = false)} {disabled}>Cancel</Button>
<Button type="submit" form="upload-image-form" {disabled}>Submit</Button>
</DialogFooter>
</DialogContent>

View File

@ -0,0 +1,52 @@
import type { CreatePostParams } from '$lib/post/application/gateway/postRepository';
export class CreatePostRequestDto {
readonly semanticId: string;
readonly title: string;
readonly description: string;
readonly content: string;
readonly labelIds: number[];
readonly previewImageUrl: URL;
readonly publishedTime: Date | null;
private constructor(props: {
semanticId: string;
title: string;
description: string;
content: string;
labelIds: number[];
previewImageUrl: URL;
publishedTime: Date | null;
}) {
this.semanticId = props.semanticId;
this.title = props.title;
this.description = props.description;
this.content = props.content;
this.labelIds = props.labelIds;
this.previewImageUrl = props.previewImageUrl;
this.publishedTime = props.publishedTime;
}
static fromParams(params: CreatePostParams): CreatePostRequestDto {
return new CreatePostRequestDto({
...params,
description: '',
content: '',
labelIds: [],
previewImageUrl: new URL('https://example.com'),
publishedTime: null,
});
}
toJson() {
return {
semantic_id: this.semanticId,
title: this.title,
description: this.description,
content: this.content,
label_ids: this.labelIds,
preview_image_url: this.previewImageUrl,
published_time: this.publishedTime,
};
}
}

View File

@ -1,7 +1,9 @@
import type { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
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: string): Promise<PostResponseDto | null>;
createPost(payload: CreatePostRequestDto): Promise<PostResponseDto>;
}

View File

@ -1,5 +1,6 @@
import { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import type { CreatePostParams, PostRepository } from '$lib/post/application/gateway/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
@ -15,4 +16,10 @@ export class PostRepositoryImpl implements PostRepository {
const dto = await this.postApiService.getPost(id);
return dto?.toEntity() ?? null;
}
async createPost(params: CreatePostParams): Promise<Post> {
const requestDto = CreatePostRequestDto.fromParams(params);
const responseDto = await this.postApiService.createPost(requestDto);
return responseDto.toEntity();
}
}

View File

@ -4,12 +4,14 @@ import {
type AsyncState,
} from '$lib/common/adapter/presenter/asyncState';
import { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
import type { CreatePostParams } from '$lib/post/application/gateway/postRepository';
import type { CreatePostUseCase } from '$lib/post/application/useCase/createPostUseCase';
import type { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { captureException } from '@sentry/sveltekit';
import { get, writable } from 'svelte/store';
export type PostState = AsyncState<PostViewModel>;
export type PostEvent = PostLoadedEvent;
export type PostEvent = PostLoadedEvent | PostCreatedEvent;
export class PostBloc {
private readonly state = writable<PostState>({
@ -18,6 +20,7 @@ export class PostBloc {
constructor(
private readonly getPostUseCase: GetPostUseCase,
private readonly createPostUseCase: CreatePostUseCase,
initialData?: PostViewModel
) {
this.state.set({
@ -34,6 +37,9 @@ export class PostBloc {
switch (event.event) {
case PostEventType.PostLoadedEvent:
return this.loadPost(event.id);
case PostEventType.PostCreatedEvent:
const { semanticId, title } = event;
return this.createPost({ semanticId, title });
}
}
@ -58,13 +64,37 @@ export class PostBloc {
this.state.set(result);
return result;
}
private async createPost(params: CreatePostParams): Promise<PostState> {
this.state.set(StateFactory.loading(get(this.state).data));
let result: PostState;
try {
const post = await this.createPostUseCase.execute(params);
const postViewModel = PostViewModel.fromEntity(post);
result = StateFactory.success(postViewModel);
} catch (e) {
result = StateFactory.error(e);
captureException(e);
}
this.state.set(result);
return result;
}
}
export enum PostEventType {
PostLoadedEvent,
PostCreatedEvent,
}
interface PostLoadedEvent {
event: PostEventType.PostLoadedEvent;
id: string;
}
interface PostCreatedEvent {
event: PostEventType.PostCreatedEvent;
semanticId: string;
title: string;
}

View File

@ -4,4 +4,10 @@ import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export interface PostRepository {
getAllPosts(): Promise<PostInfo[]>;
getPost(id: string): Promise<Post | null>;
createPost(params: CreatePostParams): Promise<Post>;
}
export interface CreatePostParams {
semanticId: string;
title: string;
}

View File

@ -0,0 +1,13 @@
import type {
CreatePostParams,
PostRepository,
} from '$lib/post/application/gateway/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
export class CreatePostUseCase {
constructor(private readonly postRepository: PostRepository) {}
async execute(params: CreatePostParams): Promise<Post> {
return this.postRepository.createPost(params);
}
}

View File

@ -1,6 +1,7 @@
import { HttpError } from '$lib/common/framework/web/httpError';
import { HttpStatusCode } from '$lib/common/framework/web/httpStatusCode';
import { Environment } from '$lib/environment';
import type { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
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';
@ -17,8 +18,8 @@ export class PostApiServiceImpl implements PostApiService {
throw new HttpError(response.status, url);
}
const json = await response.json();
return json.map(PostInfoResponseDto.fromJson);
const data = await response.json();
return data.map(PostInfoResponseDto.fromJson);
}
async getPost(id: string): Promise<PostResponseDto | null> {
@ -34,7 +35,24 @@ export class PostApiServiceImpl implements PostApiService {
throw new HttpError(response.status, url);
}
const json = await response.json();
return PostResponseDto.fromJson(json);
const data = await response.json();
return PostResponseDto.fromJson(data);
}
async createPost(payload: CreatePostRequestDto): Promise<PostResponseDto> {
const url = new URL('post', Environment.API_BASE_URL);
const response = await this.fetchFn(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload.toJson()),
});
if (!response.ok) {
throw new HttpError(response.status, url);
}
const data = await response.json();
return PostResponseDto.fromJson(data);
}
}

View File

@ -0,0 +1,107 @@
<script module lang="ts">
import z from 'zod';
const formSchema = z.object({
semanticId: z
.string()
.max(100)
.regex(/\D/)
.regex(/^[a-zA-Z0-9_\-]+$/),
title: z.string().trim().nonempty().max(100),
});
type FormParams = z.infer<typeof formSchema>;
export type CreatePostDialogFormParams = FormParams;
</script>
<script lang="ts">
import { Button, buttonVariants } from '$lib/common/framework/components/ui/button';
import { Dialog } from '$lib/common/framework/components/ui/dialog';
import DialogContent from '$lib/common/framework/components/ui/dialog/dialog-content.svelte';
import DialogFooter from '$lib/common/framework/components/ui/dialog/dialog-footer.svelte';
import DialogHeader from '$lib/common/framework/components/ui/dialog/dialog-header.svelte';
import DialogTitle from '$lib/common/framework/components/ui/dialog/dialog-title.svelte';
import DialogTrigger from '$lib/common/framework/components/ui/dialog/dialog-trigger.svelte';
import Input from '$lib/common/framework/components/ui/input/input.svelte';
import Label from '$lib/common/framework/components/ui/label/label.svelte';
const {
disabled,
onSubmit: createPost,
}: {
disabled: boolean;
onSubmit: (params: FormParams) => Promise<void>;
} = $props();
let open = $state(false);
let formData = $state<FormParams>({ semanticId: '', title: '' });
let formErrors = $state<Partial<Record<keyof FormParams, string>>>({});
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
formErrors = {};
const parseResult = formSchema.safeParse(formData);
if (parseResult.error) {
const errorResult = z.treeifyError(parseResult.error).properties;
if (errorResult?.semanticId?.errors) {
formErrors.semanticId = errorResult.semanticId.errors[0];
}
if (errorResult?.title?.errors) {
formErrors.title = errorResult.title.errors[0];
}
return;
}
await createPost(formData);
formData = { semanticId: '', title: '' };
open = false;
}
</script>
<Dialog bind:open>
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Create</DialogTrigger>
<DialogContent
showCloseButton={false}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeydown={(e) => e.preventDefault()}
>
<DialogHeader class="mb-4">
<DialogTitle>Create Post</DialogTitle>
</DialogHeader>
<form id="create-post-form" onsubmit={onSubmit} class="space-y-3">
<div>
<Label for="semantic-id-input" class="pb-2">Semantic ID</Label>
<Input
id="semantic-id-input"
type="text"
aria-invalid={formErrors.semanticId !== undefined}
bind:value={formData.semanticId}
/>
{#if formErrors.semanticId}
<p class="text-sm text-red-500">{formErrors.semanticId}</p>
{/if}
</div>
<div>
<Label for="title-input" class="pb-2">Title</Label>
<Input
id="title-input"
type="text"
aria-invalid={formErrors.title !== undefined}
bind:value={formData.title}
/>
{#if formErrors.title}
<p class="text-sm text-red-500">{formErrors.title}</p>
{/if}
</div>
</form>
<DialogFooter class="mt-6">
<Button variant="outline" onclick={() => (open = false)} {disabled}>Cancel</Button>
<Button type="submit" form="create-post-form" {disabled}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
import { PostBloc, PostEventType } from '$lib/post/adapter/presenter/postBloc';
import CreatePostDialog, {
type CreatePostDialogFormParams,
} from '$lib/post/framework/ui/CreatePostDialog.svelte';
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
const postBloc = getContext<PostBloc>(PostBloc.name);
const state = $derived($postBloc);
const isLoading = $derived(state.status === StatusType.Loading);
async function onCreatePostDialogSubmit(params: CreatePostDialogFormParams) {
const state = await postBloc.dispatch({ event: PostEventType.PostCreatedEvent, ...params });
if (state.status === StatusType.Success) {
toast.success(`Post created successfully with ID: ${state.data.id}`);
} else if (state.status === StatusType.Error) {
toast.error('Failed to create post', {
description: state.error?.message ?? 'Unknown error',
});
}
}
</script>
<div class="dashboard-container mb-10">
<div class="flex flex-row items-center justify-between">
<h1 class="py-16 text-5xl font-bold text-gray-800">Post</h1>
<CreatePostDialog disabled={isLoading} onSubmit={onCreatePostDialogSubmit} />
</div>
<p></p>
</div>

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import ErrorPage from '$lib/common/framework/ui/ErrorPage.svelte';
</script>

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import HomePage from '$lib/home/framework/ui/HomePage.svelte';
</script>

View File

@ -1,18 +1,11 @@
<script lang="ts">
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
import { ImageRepositoryImpl } from '$lib/image/adapter/gateway/imageRepositoryImpl';
import { Container } from '$lib/container';
import { ImageBloc } from '$lib/image/adapter/presenter/imageBloc';
import type { ImageRepository } from '$lib/image/application/gateway/imageRepository';
import { UploadImageUseCase } from '$lib/image/application/useCase/uploadImageUseCase';
import { ImageApiServiceImpl } from '$lib/image/framework/api/imageApiServiceImpl';
import ImageManagementPage from '$lib/image/framework/ui/ImageManagementPage.svelte';
import { setContext } from 'svelte';
const imageApiService: ImageApiService = new ImageApiServiceImpl(fetch);
const imageRepository: ImageRepository = new ImageRepositoryImpl(imageApiService);
const uploadImageUseCase = new UploadImageUseCase(imageRepository);
const imageBloc = new ImageBloc(uploadImageUseCase);
import { getContext, setContext } from 'svelte';
const container = getContext<Container>(Container.name);
const imageBloc = container.createImageBloc();
setContext(ImageBloc.name, imageBloc);
</script>

View File

@ -1 +1,12 @@
<div>Post</div>
<script lang="ts">
import { Container } from '$lib/container';
import { PostBloc } from '$lib/post/adapter/presenter/postBloc';
import PostManagementPage from '$lib/post/framework/ui/PostManagementPage.svelte';
import { getContext, setContext } from 'svelte';
const container = getContext<Container>(Container.name);
const postBloc = container.createPostBloc();
setContext(PostBloc.name, postBloc);
</script>
<PostManagementPage />