BLOG-126 Post management (list and create) #139
@ -6,7 +6,7 @@ import type { BaseStore } from '$lib/common/adapter/presenter/baseStore';
|
||||
import { captureException } from '@sentry/sveltekit';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
export type AuthState = AsyncState<AuthViewModel>;
|
||||
type AuthState = AsyncState<AuthViewModel>;
|
||||
|
||||
export class AuthLoadedStore implements BaseStore<AuthState> {
|
||||
private readonly state = writable<AuthState>(AsyncState.idle<AuthViewModel>(null));
|
||||
|
@ -0,0 +1,28 @@
|
||||
import Root from './table.svelte';
|
||||
import Body from './table-body.svelte';
|
||||
import Caption from './table-caption.svelte';
|
||||
import Cell from './table-cell.svelte';
|
||||
import Footer from './table-footer.svelte';
|
||||
import Head from './table-head.svelte';
|
||||
import Header from './table-header.svelte';
|
||||
import Row from './table-row.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Body,
|
||||
Caption,
|
||||
Cell,
|
||||
Footer,
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
Cell as TableCell,
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow,
|
||||
};
|
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tbody
|
||||
bind:this={ref}
|
||||
data-slot="table-body"
|
||||
class={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tbody>
|
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<caption
|
||||
bind:this={ref}
|
||||
data-slot="table-caption"
|
||||
class={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</caption>
|
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||
import type { HTMLTdAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTdAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<td
|
||||
bind:this={ref}
|
||||
data-slot="table-cell"
|
||||
class={cn(
|
||||
'bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</td>
|
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tfoot
|
||||
bind:this={ref}
|
||||
data-slot="table-footer"
|
||||
class={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tfoot>
|
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||
import type { HTMLThAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLThAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<th
|
||||
bind:this={ref}
|
||||
data-slot="table-head"
|
||||
class={cn(
|
||||
'h-10 bg-clip-padding px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</th>
|
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<thead
|
||||
bind:this={ref}
|
||||
data-slot="table-header"
|
||||
class={cn('[&_tr]:border-b', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</thead>
|
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tr
|
||||
bind:this={ref}
|
||||
data-slot="table-row"
|
||||
class={cn(
|
||||
'border-b transition-colors data-[state=selected]:bg-muted hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tr>
|
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTableAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTableAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="table-container" class="relative w-full overflow-x-auto">
|
||||
<table
|
||||
bind:this={ref}
|
||||
data-slot="table"
|
||||
class={cn('w-full caption-bottom text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</table>
|
||||
</div>
|
@ -6,7 +6,7 @@ import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentU
|
||||
import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl';
|
||||
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
|
||||
import { ImageRepositoryImpl } from '$lib/image/adapter/gateway/imageRepositoryImpl';
|
||||
import { ImageUploadedStore } from '$lib/image/adapter/presenter/ImageUploadedStore';
|
||||
import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore';
|
||||
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';
|
||||
|
@ -2,7 +2,7 @@
|
||||
import { getContext } from 'svelte';
|
||||
import UploadImageDialoag from '$lib/image/framework/ui/UploadImageDialoag.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { ImageUploadedStore } from '$lib/image/adapter/presenter/ImageUploadedStore';
|
||||
import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore';
|
||||
|
||||
const store = getContext<ImageUploadedStore>(ImageUploadedStore.name);
|
||||
const state = $derived($store);
|
||||
|
@ -1,9 +1,10 @@
|
||||
import type { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
|
||||
import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto';
|
||||
import type { PostListQueryDto } from '$lib/post/adapter/gateway/postListQueryDto';
|
||||
import type { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
|
||||
|
||||
export interface PostApiService {
|
||||
getAllPosts(): Promise<PostInfoResponseDto[]>;
|
||||
getAllPosts(searchParams: PostListQueryDto): Promise<PostInfoResponseDto[]>;
|
||||
getPost(id: string): Promise<PostResponseDto | null>;
|
||||
createPost(payload: CreatePostRequestDto): Promise<PostResponseDto>;
|
||||
}
|
||||
|
13
frontend/src/lib/post/adapter/gateway/postListQueryDto.ts
Normal file
13
frontend/src/lib/post/adapter/gateway/postListQueryDto.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export class PostListQueryDto {
|
||||
readonly showUnpublished: boolean;
|
||||
|
||||
constructor(props: { showUnpublished: boolean }) {
|
||||
this.showUnpublished = props.showUnpublished;
|
||||
}
|
||||
|
||||
toSearchParams(): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
params.append('is_published_only', (!this.showUnpublished).toString());
|
||||
return params;
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
|
||||
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
|
||||
import { PostListQueryDto } from '$lib/post/adapter/gateway/postListQueryDto';
|
||||
import type {
|
||||
CreatePostParams,
|
||||
PostRepository,
|
||||
@ -10,9 +11,10 @@ import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||
export class PostRepositoryImpl implements PostRepository {
|
||||
constructor(private readonly postApiService: PostApiService) {}
|
||||
|
||||
async getAllPosts(): Promise<PostInfo[]> {
|
||||
const dtos = await this.postApiService.getAllPosts();
|
||||
return dtos.map((dto) => dto.toEntity());
|
||||
async getAllPosts(showUnpublished: boolean): Promise<PostInfo[]> {
|
||||
const queryDto = new PostListQueryDto({ showUnpublished });
|
||||
const responseDtos = await this.postApiService.getAllPosts(queryDto);
|
||||
return responseDtos.map((dto) => dto.toEntity());
|
||||
}
|
||||
|
||||
async getPost(id: string): Promise<Post | null> {
|
||||
|
@ -5,7 +5,7 @@ import type { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCas
|
||||
import { captureException } from '@sentry/sveltekit';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
export type PostState = AsyncState<PostViewModel>;
|
||||
type PostState = AsyncState<PostViewModel>;
|
||||
|
||||
export class PostLoadedStore implements BaseStore<PostState, string> {
|
||||
private readonly state = writable<PostState>(AsyncState.idle<PostViewModel>(null));
|
||||
|
@ -5,9 +5,9 @@ import type { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPos
|
||||
import { captureException } from '@sentry/sveltekit';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
export type PostListState = AsyncState<readonly PostInfoViewModel[]>;
|
||||
type PostListState = AsyncState<readonly PostInfoViewModel[]>;
|
||||
|
||||
export class PostsListedStore implements BaseStore<PostListState> {
|
||||
export class PostsListedStore implements BaseStore<PostListState, { showUnpublished: boolean }> {
|
||||
private readonly state = writable<PostListState>(AsyncState.idle([]));
|
||||
|
||||
constructor(
|
||||
@ -24,15 +24,15 @@ export class PostsListedStore implements BaseStore<PostListState> {
|
||||
}
|
||||
|
||||
get trigger() {
|
||||
return () => this.loadPosts();
|
||||
return (options?: { showUnpublished: boolean }) => this.loadPosts(options?.showUnpublished);
|
||||
}
|
||||
|
||||
private async loadPosts(): Promise<PostListState> {
|
||||
private async loadPosts(showUnpublished?: boolean): Promise<PostListState> {
|
||||
this.state.set(AsyncState.loading(get(this.state).data));
|
||||
|
||||
let result: PostListState;
|
||||
try {
|
||||
const posts = await this.getAllPostsUseCase.execute();
|
||||
const posts = await this.getAllPostsUseCase.execute(showUnpublished);
|
||||
const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post));
|
||||
result = AsyncState.success(postViewModels);
|
||||
} catch (e) {
|
||||
|
@ -2,7 +2,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[]>;
|
||||
getAllPosts(showUnpublished: boolean): Promise<PostInfo[]>;
|
||||
getPost(id: string): Promise<Post | null>;
|
||||
createPost(params: CreatePostParams): Promise<Post>;
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||
export class GetAllPostsUseCase {
|
||||
constructor(private readonly postRepository: PostRepository) {}
|
||||
|
||||
execute(): Promise<PostInfo[]> {
|
||||
return this.postRepository.getAllPosts();
|
||||
execute(showUnpublished: boolean = false): Promise<PostInfo[]> {
|
||||
return this.postRepository.getAllPosts(showUnpublished);
|
||||
}
|
||||
}
|
||||
|
@ -4,13 +4,15 @@ 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 type { PostListQueryDto } from '$lib/post/adapter/gateway/postListQueryDto';
|
||||
import { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
|
||||
|
||||
export class PostApiServiceImpl implements PostApiService {
|
||||
constructor(private readonly fetchFn: typeof fetch) {}
|
||||
|
||||
async getAllPosts(): Promise<PostInfoResponseDto[]> {
|
||||
async getAllPosts(searchParams: PostListQueryDto): Promise<PostInfoResponseDto[]> {
|
||||
const url = new URL('post', Environment.API_BASE_URL);
|
||||
url.search = searchParams.toSearchParams().toString();
|
||||
|
||||
const response = await this.fetchFn(url);
|
||||
|
||||
|
@ -34,7 +34,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<article class="content-container prose prose-gray pb-10">
|
||||
<article class="content-container prose pb-10 prose-gray">
|
||||
{#if postInfo}
|
||||
<PostContentHeader {postInfo} />
|
||||
<div class="max-w-3xl">
|
||||
|
@ -1,14 +1,26 @@
|
||||
<script lang="ts">
|
||||
import TableBody from '$lib/common/framework/components/ui/table/table-body.svelte';
|
||||
import TableCell from '$lib/common/framework/components/ui/table/table-cell.svelte';
|
||||
import TableHead from '$lib/common/framework/components/ui/table/table-head.svelte';
|
||||
import TableHeader from '$lib/common/framework/components/ui/table/table-header.svelte';
|
||||
import TableRow from '$lib/common/framework/components/ui/table/table-row.svelte';
|
||||
import Table from '$lib/common/framework/components/ui/table/table.svelte';
|
||||
import { PostCreatedStore } from '$lib/post/adapter/presenter/postCreatedStore';
|
||||
import { PostsListedStore } from '$lib/post/adapter/presenter/postsListedStore';
|
||||
import CreatePostDialog, {
|
||||
type CreatePostDialogFormParams,
|
||||
} from '$lib/post/framework/ui/CreatePostDialog.svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import PostLabel from '$lib/post/framework/ui/PostLabel.svelte';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const store = getContext<PostCreatedStore>(PostCreatedStore.name);
|
||||
const state = $derived($store);
|
||||
const { trigger: createPost } = store;
|
||||
const postCreatedStore = getContext<PostCreatedStore>(PostCreatedStore.name);
|
||||
const postCreatedState = $derived($postCreatedStore);
|
||||
const { trigger: createPost } = postCreatedStore;
|
||||
|
||||
const postsListedStore = getContext<PostsListedStore>(PostsListedStore.name);
|
||||
const postsListedState = $derived($postsListedStore);
|
||||
const { trigger: loadPosts } = postsListedStore;
|
||||
|
||||
async function onCreatePostDialogSubmit(params: CreatePostDialogFormParams) {
|
||||
const state = await createPost(params);
|
||||
@ -21,12 +33,39 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => loadPosts({ showUnpublished: true }));
|
||||
</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={state.isLoading()} onSubmit={onCreatePostDialogSubmit} />
|
||||
<CreatePostDialog disabled={postCreatedState.isLoading()} onSubmit={onCreatePostDialogSubmit} />
|
||||
</div>
|
||||
<p></p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
<TableHead>Published Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#if postsListedState.isSuccess()}
|
||||
{#each postsListedState.data as postInfo (postInfo.id)}
|
||||
<TableRow>
|
||||
<TableCell>{postInfo.id}</TableCell>
|
||||
<TableCell>{postInfo.title}</TableCell>
|
||||
<TableCell class="flex flex-row flex-wrap gap-2">
|
||||
{#each postInfo.labels as label (label.id)}
|
||||
<PostLabel {label} />
|
||||
{/each}
|
||||
</TableCell>
|
||||
<TableCell>{postInfo.formattedPublishedTime || '---'}</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
@ -31,7 +31,7 @@
|
||||
{:else if !isAuthenticated}
|
||||
<ErrorPage />
|
||||
{:else}
|
||||
<div class="min-h-content-height grid grid-cols-[auto_1fr]">
|
||||
<div class="grid min-h-content-height grid-cols-[auto_1fr]">
|
||||
<DashboardNavbar {links} />
|
||||
{@render children()}
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Container } from '$lib/container';
|
||||
import { ImageUploadedStore } from '$lib/image/adapter/presenter/ImageUploadedStore';
|
||||
import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore';
|
||||
import ImageManagementPage from '$lib/image/framework/ui/ImageManagementPage.svelte';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
|
||||
|
13
frontend/src/routes/dashboard/post/+page.server.ts
Normal file
13
frontend/src/routes/dashboard/post/+page.server.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { container } = locals;
|
||||
const store = container.createPostsListedStore();
|
||||
const { trigger: loadPosts } = store;
|
||||
|
||||
const state = await loadPosts();
|
||||
|
||||
return {
|
||||
dehydratedData: state.data?.map((post) => post.dehydrate()),
|
||||
};
|
||||
};
|
@ -1,12 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Container } from '$lib/container';
|
||||
import { PostCreatedStore } from '$lib/post/adapter/presenter/postCreatedStore';
|
||||
import { PostsListedStore } from '$lib/post/adapter/presenter/postsListedStore';
|
||||
import PostManagementPage from '$lib/post/framework/ui/PostManagementPage.svelte';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { PageProps } from './$types';
|
||||
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
const container = getContext<Container>(Container.name);
|
||||
const store = container.createPostCreatedStore();
|
||||
setContext(PostCreatedStore.name, store);
|
||||
|
||||
const postCreatedStore = container.createPostCreatedStore();
|
||||
setContext(PostCreatedStore.name, postCreatedStore);
|
||||
|
||||
const initialData = data.dehydratedData?.map((post) => PostInfoViewModel.rehydrate(post));
|
||||
const postsListedStore = container.createPostsListedStore(initialData);
|
||||
setContext(PostsListedStore.name, postsListedStore);
|
||||
</script>
|
||||
|
||||
<PostManagementPage />
|
||||
|
Loading…
x
Reference in New Issue
Block a user