feat: enhance post listing functionality with unpublished post visibility option
Some checks failed
Frontend CI / build (push) Failing after 1m13s
Auto Comment On PR / add_improve_comment (pull_request) Successful in 17s
PR Title Check / pr-title-check (pull_request) Successful in 16s

This commit is contained in:
SquidSpirit 2025-10-15 04:05:28 +08:00
parent 4472a5de4b
commit 5890d6c39c
26 changed files with 306 additions and 28 deletions

View File

@ -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));

View File

@ -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,
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

@ -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);

View File

@ -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>;
}

View 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;
}
}

View File

@ -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> {

View File

@ -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));

View File

@ -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) {

View File

@ -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>;
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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';

View 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()),
};
};

View File

@ -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 />