BLOG-126 Post management (list and create) (#139)
All checks were successful
Frontend CI / build (push) Successful in 6m14s

### Description

- As the title

### Package Changes

_No response_

### Screenshots

|Scenario|Screenshot|
|-|-|
|Posts list|![截圖 2025-10-15 凌晨4.09.28.png](/attachments/c8ad41e8-1065-4249-90e3-977bd36031e8)|
|Empty input|![截圖 2025-10-15 凌晨4.10.12.png](/attachments/c902fdc0-4287-4b5d-9b9e-63dd6a5604a6)|
|Pattern not matched|![截圖 2025-10-15 凌晨4.11.05.png](/attachments/88e4fb1d-6a69-4305-a94c-bd48982bb8a5)|

### Reference

Resolve #126.

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #139
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
This commit is contained in:
SquidSpirit 2025-10-15 04:21:13 +08:00 committed by squid
parent de2099011b
commit 24a98f8f70
67 changed files with 1177 additions and 441 deletions

View File

@ -12,8 +12,8 @@ pub struct CreatePostRequestDto {
pub content: String, pub content: String,
pub label_ids: Vec<i32>, pub label_ids: Vec<i32>,
#[schema(format = Uri)] #[schema(required, format = Uri)]
pub preview_image_url: String, pub preview_image_url: Option<String>,
#[schema(required, format = DateTime)] #[schema(required, format = DateTime)]
pub published_time: Option<String>, pub published_time: Option<String>,

View File

@ -14,7 +14,7 @@ pub struct PostInfoResponseDto {
pub labels: Vec<LabelResponseDto>, pub labels: Vec<LabelResponseDto>,
#[schema(format = Uri)] #[schema(format = Uri)]
pub preview_image_url: String, pub preview_image_url: Option<String>,
#[schema(format = DateTime)] #[schema(format = DateTime)]
pub published_time: Option<String>, pub published_time: Option<String>,

View File

@ -12,8 +12,8 @@ pub struct UpdatePostRequestDto {
pub content: String, pub content: String,
pub label_ids: Vec<i32>, pub label_ids: Vec<i32>,
#[schema(format = Uri)] #[schema(required, format = Uri)]
pub preview_image_url: String, pub preview_image_url: Option<String>,
#[schema(required, format = DateTime)] #[schema(required, format = DateTime)]
pub published_time: Option<String>, pub published_time: Option<String>,

View File

@ -7,7 +7,7 @@ pub struct PostInfoMapper {
pub semantic_id: String, pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: Option<String>,
pub published_time: Option<NaiveDateTime>, pub published_time: Option<NaiveDateTime>,
pub labels: Vec<LabelMapper>, pub labels: Vec<LabelMapper>,
} }

View File

@ -10,7 +10,7 @@ pub struct PostInfo {
pub semantic_id: String, pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: Option<String>,
pub labels: Vec<Label>, pub labels: Vec<Label>,
pub published_time: Option<DateTime<Utc>>, pub published_time: Option<DateTime<Utc>>,
} }

View File

@ -6,7 +6,7 @@ pub struct PostInfoWithLabelRecord {
pub semantic_id: String, pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: Option<String>,
pub published_time: Option<NaiveDateTime>, pub published_time: Option<NaiveDateTime>,
pub label_id: Option<i32>, pub label_id: Option<i32>,

View File

@ -6,7 +6,7 @@ pub struct PostWithLabelRecord {
pub semantic_id: String, pub semantic_id: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub preview_image_url: String, pub preview_image_url: Option<String>,
pub content: String, pub content: String,
pub published_time: Option<NaiveDateTime>, pub published_time: Option<NaiveDateTime>,

View File

@ -0,0 +1,2 @@
-- Revert post.preview_image_url to NOT NULL
ALTER TABLE "post" ALTER COLUMN "preview_image_url" SET NOT NULL;

View File

@ -0,0 +1,2 @@
-- Make post.preview_image_url nullable
ALTER TABLE "post" ALTER COLUMN "preview_image_url" DROP NOT NULL;

View File

@ -5,9 +5,7 @@ declare global {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
authBloc: import('$lib/auth/adapter/presenter/authBloc').AuthBloc; container: import('$lib/container').Container;
postListBloc: import('$lib/post/adapter/presenter/postListBloc').PostListBloc;
postBloc: import('$lib/post/adapter/presenter/postBloc').PostBloc;
} }
// interface PageData {} // interface PageData {}

View File

@ -1,21 +1,8 @@
import { sequence } from '@sveltejs/kit/hooks'; import { sequence } from '@sveltejs/kit/hooks';
import * as Sentry from '@sentry/sveltekit'; import * as Sentry from '@sentry/sveltekit';
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 { 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'; import type { Handle } from '@sveltejs/kit';
import { Environment } from '$lib/environment'; import { Environment } from '$lib/environment';
import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl'; import { Container } from '$lib/container';
import { AuthRepositoryImpl } from '$lib/auth/adapter/gateway/authRepositoryImpl';
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import { AuthBloc } from '$lib/auth/adapter/presenter/authBloc';
import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
Sentry.init({ Sentry.init({
dsn: Environment.SENTRY_DSN, dsn: Environment.SENTRY_DSN,
@ -24,18 +11,7 @@ Sentry.init({
}); });
export const handle: Handle = sequence(Sentry.sentryHandle(), ({ event, resolve }) => { export const handle: Handle = sequence(Sentry.sentryHandle(), ({ event, resolve }) => {
const authApiService: AuthApiService = new AuthApiServiceImpl(event.fetch); event.locals.container ??= new Container(event.fetch);
const authRepository: AuthRepository = new AuthRepositoryImpl(authApiService);
const getCurrentUserUseCase = new GetCurrentUserUseCase(authRepository);
const postApiService: PostApiService = new PostApiServiceImpl(event.fetch);
const postRepository: PostRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const getPostUseCase = new GetPostUseCase(postRepository);
event.locals.authBloc = new AuthBloc(getCurrentUserUseCase);
event.locals.postListBloc = new PostListBloc(getAllPostsUseCase);
event.locals.postBloc = new PostBloc(getPostUseCase);
return resolve(event); return resolve(event);
}); });

View File

@ -1,59 +0,0 @@
import { AuthViewModel } from '$lib/auth/adapter/presenter/authViewModel';
import { UserViewModel } from '$lib/auth/adapter/presenter/userViewModel';
import type { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
import { get, writable } from 'svelte/store';
export type AuthState = AsyncState<AuthViewModel>;
export type AuthEvent = CurrentUserLoadedEvent;
export class AuthBloc {
private readonly state = writable<AuthState>({
status: StatusType.Idle,
});
constructor(
private readonly getCurrentUserUseCase: GetCurrentUserUseCase,
initialData?: AuthViewModel
) {
this.state.set({
status: StatusType.Idle,
data: initialData,
});
}
get subscribe() {
return this.state.subscribe;
}
async dispatch(event: AuthEvent): Promise<AuthState> {
switch (event.event) {
case AuthEventType.CurrentUserLoadedEvent:
return this.loadCurrentUser();
}
}
private async loadCurrentUser(): Promise<AuthState> {
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
const user = await this.getCurrentUserUseCase.execute();
const userViewModel = user ? UserViewModel.fromEntity(user) : null;
const authViewModel = AuthViewModel.fromEntity(userViewModel);
const result: AuthState = {
status: StatusType.Success,
data: authViewModel,
};
this.state.set(result);
return result;
}
}
export enum AuthEventType {
CurrentUserLoadedEvent,
}
interface CurrentUserLoadedEvent {
event: AuthEventType.CurrentUserLoadedEvent;
}

View File

@ -0,0 +1,41 @@
import { AuthViewModel } from '$lib/auth/adapter/presenter/authViewModel';
import { UserViewModel } from '$lib/auth/adapter/presenter/userViewModel';
import type { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
import { AsyncState } from '$lib/common/adapter/presenter/asyncState';
import type { BaseStore } from '$lib/common/adapter/presenter/baseStore';
import { captureException } from '@sentry/sveltekit';
import { get, writable } from 'svelte/store';
type AuthState = AsyncState<AuthViewModel>;
export class AuthLoadedStore implements BaseStore<AuthState> {
private readonly state = writable<AuthState>(AsyncState.idle<AuthViewModel>(null));
constructor(private readonly getCurrentUserUseCase: GetCurrentUserUseCase) {}
get subscribe() {
return this.state.subscribe;
}
get trigger() {
return () => this.loadCurrentUser();
}
private async loadCurrentUser(): Promise<AuthState> {
this.state.set(AsyncState.loading(get(this.state).data));
let result: AuthState;
try {
const user = await this.getCurrentUserUseCase.execute();
const userViewModel = user ? UserViewModel.fromEntity(user) : null;
const authViewModel = AuthViewModel.fromEntity(userViewModel);
result = AsyncState.success(authViewModel);
} catch (e) {
result = AsyncState.error(e, get(this.state).data);
captureException(e);
}
this.state.set(result);
return result;
}
}

View File

@ -1,5 +1,7 @@
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService'; import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import { UserResponseDto } from '$lib/auth/adapter/gateway/userResponseDto'; import { UserResponseDto } from '$lib/auth/adapter/gateway/userResponseDto';
import { HttpError } from '$lib/common/framework/web/httpError';
import { HttpStatusCode } from '$lib/common/framework/web/httpStatusCode';
import { Environment } from '$lib/environment'; import { Environment } from '$lib/environment';
export class AuthApiServiceImpl implements AuthApiService { export class AuthApiServiceImpl implements AuthApiService {
@ -10,10 +12,14 @@ export class AuthApiServiceImpl implements AuthApiService {
const response = await this.fetchFn(url); const response = await this.fetchFn(url);
if (!response.ok) { if (response.status === HttpStatusCode.UNAUTHORIZED) {
return null; return null;
} }
if (!response.ok) {
throw new HttpError(response.status, url);
}
const json = await response.json(); const json = await response.json();
return UserResponseDto.fromJson(json); return UserResponseDto.fromJson(json);
} }

View File

@ -1,29 +1,110 @@
export enum StatusType { enum AsyncStateStatus {
Idle, Idle = 'idle',
Loading, Loading = 'loading',
Success, Success = 'success',
Error, Error = 'error',
} }
export interface IdleState<T> { export abstract class AsyncState<T> {
status: StatusType.Idle; abstract readonly status: AsyncStateStatus;
data?: T; abstract readonly data: T | null;
abstract readonly error: Error | null;
static idle<T>(data: T | null): IdleState<T> {
return new IdleState(data);
} }
export interface LoadingState<T> { static loading<T>(data: T | null): LoadingState<T> {
status: StatusType.Loading; return new LoadingState(data);
data?: T;
} }
export interface SuccessState<T> { static success<T>(data: T): SuccessState<T> {
status: StatusType.Success; return new SuccessState(data);
data: T;
} }
export interface ErrorState<T> { static error<T>(error: unknown, data: T | null): ErrorState<T> {
status: StatusType.Error; const errorInstance = error instanceof Error ? error : new Error(String(error));
data?: T; return new ErrorState(errorInstance, data);
error: Error;
} }
export type AsyncState<T> = IdleState<T> | LoadingState<T> | SuccessState<T> | ErrorState<T>; isIdle(): this is IdleState<T> {
return this.status === AsyncStateStatus.Idle;
}
isLoading(): this is LoadingState<T> {
return this.status === AsyncStateStatus.Loading;
}
isSuccess(): this is SuccessState<T> {
return this.status === AsyncStateStatus.Success;
}
isError(): this is ErrorState<T> {
return this.status === AsyncStateStatus.Error;
}
}
class IdleState<T> extends AsyncState<T> {
readonly status = AsyncStateStatus.Idle;
readonly data: T | null;
readonly error = null;
constructor(data: T | null) {
super();
this.data = data;
}
toLoading(): LoadingState<T> {
return new LoadingState(this.data);
}
}
class LoadingState<T> extends AsyncState<T> {
readonly status = AsyncStateStatus.Loading;
readonly data: T | null;
readonly error = null;
constructor(data: T | null) {
super();
this.data = data;
}
toSuccess(data: T): SuccessState<T> {
return new SuccessState(data);
}
toError(error: Error): ErrorState<T> {
return new ErrorState(error, this.data);
}
}
class SuccessState<T> extends AsyncState<T> {
readonly status = AsyncStateStatus.Success;
readonly data: T;
readonly error = null;
constructor(data: T) {
super();
this.data = data;
}
toLoading(): LoadingState<T> {
return new LoadingState(this.data);
}
}
class ErrorState<T> extends AsyncState<T> {
readonly status = AsyncStateStatus.Error;
readonly data: T | null;
readonly error: Error;
constructor(error: Error, data: T | null) {
super();
this.error = error;
this.data = data;
}
toLoading(): LoadingState<T> {
return new LoadingState(this.data);
}
}

View File

@ -0,0 +1,7 @@
import type { AsyncState } from '$lib/common/adapter/presenter/asyncState';
import type { Readable } from 'svelte/store';
export interface BaseStore<T extends AsyncState<unknown>, U = void> {
get subscribe(): Readable<T>['subscribe'];
get trigger(): (arg: U) => Promise<T>;
}

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

@ -0,0 +1,8 @@
export class HttpError extends Error {
constructor(
public readonly status: number,
url: URL
) {
super(`HTTP ${status} at ${url.href}`);
}
}

View File

@ -0,0 +1,4 @@
export enum HttpStatusCode {
UNAUTHORIZED = 401,
NOT_FOUND = 404,
}

View File

@ -0,0 +1,147 @@
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import { AuthRepositoryImpl } from '$lib/auth/adapter/gateway/authRepositoryImpl';
import { AuthLoadedStore } from '$lib/auth/adapter/presenter/authLoadedStore';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
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 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 type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl';
import { PostCreatedStore } from '$lib/post/adapter/presenter/postCreatedStore';
import type { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import { PostsListedStore } from '$lib/post/adapter/presenter/postsListedStore';
import { PostLoadedStore } from '$lib/post/adapter/presenter/postLoadedStore';
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';
export class Container {
private useCases: UseCases;
constructor(fetchFn: typeof fetch) {
const apiServices = new ApiServices(fetchFn);
const repositories = new Repositories(apiServices);
this.useCases = new UseCases(repositories);
}
createAuthLoadedStore(): AuthLoadedStore {
return new AuthLoadedStore(this.useCases.getCurrentUserUseCase);
}
createImageUploadedStore(): ImageUploadedStore {
return new ImageUploadedStore(this.useCases.uploadImageUseCase);
}
createPostsListedStore(initialData?: readonly PostInfoViewModel[]): PostsListedStore {
return new PostsListedStore(this.useCases.getAllPostsUseCase, initialData);
}
createPostLoadedStore(initialData?: PostViewModel): PostLoadedStore {
return new PostLoadedStore(this.useCases.getPostUseCase, initialData);
}
createPostCreatedStore(initialData?: PostViewModel): PostCreatedStore {
return new PostCreatedStore(this.useCases.createPostUseCase, initialData);
}
}
class ApiServices {
private fetchFn: typeof fetch;
private _authApiService?: AuthApiService;
private _imageApiService?: ImageApiService;
private _postApiService?: PostApiService;
constructor(fetchFn: typeof fetch) {
this.fetchFn = fetchFn;
}
get authApiService(): AuthApiService {
this._authApiService ??= new AuthApiServiceImpl(this.fetchFn);
return this._authApiService;
}
get imageApiService(): ImageApiService {
this._imageApiService ??= new ImageApiServiceImpl(this.fetchFn);
return this._imageApiService;
}
get postApiService(): PostApiService {
this._postApiService ??= new PostApiServiceImpl(this.fetchFn);
return this._postApiService;
}
}
class Repositories {
private apiServices: ApiServices;
private _authRepository?: AuthRepository;
private _imageRepository?: ImageRepository;
private _postRepository?: PostRepository;
constructor(apiServices: ApiServices) {
this.apiServices = apiServices;
}
get authRepository(): AuthRepository {
this._authRepository ??= new AuthRepositoryImpl(this.apiServices.authApiService);
return this._authRepository;
}
get imageRepository(): ImageRepository {
this._imageRepository ??= new ImageRepositoryImpl(this.apiServices.imageApiService);
return this._imageRepository;
}
get postRepository(): PostRepository {
this._postRepository ??= new PostRepositoryImpl(this.apiServices.postApiService);
return this._postRepository;
}
}
class UseCases {
private repositories: Repositories;
private _getCurrentUserUseCase?: GetCurrentUserUseCase;
private _uploadImageUseCase?: UploadImageUseCase;
private _getAllPostsUseCase?: GetAllPostsUseCase;
private _getPostUseCase?: GetPostUseCase;
private _createPostUseCase?: CreatePostUseCase;
constructor(repositories: Repositories) {
this.repositories = repositories;
}
get getCurrentUserUseCase(): GetCurrentUserUseCase {
this._getCurrentUserUseCase ??= new GetCurrentUserUseCase(this.repositories.authRepository);
return this._getCurrentUserUseCase;
}
get uploadImageUseCase(): UploadImageUseCase {
this._uploadImageUseCase ??= new UploadImageUseCase(this.repositories.imageRepository);
return this._uploadImageUseCase;
}
get getAllPostsUseCase(): GetAllPostsUseCase {
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

@ -1,5 +1,5 @@
<script> <script>
import MottoAnimatedMark from './MottoAnimatedMark.svelte'; import MottoAnimatedMark from '$lib/home/framework/ui/MottoAnimatedMark.svelte';
</script> </script>
<div <div

View File

@ -1,50 +0,0 @@
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
import { ImageInfoViewModel } from '$lib/image/adapter/presenter/imageInfoViewModel';
import type { UploadImageUseCase } from '$lib/image/application/useCase/uploadImageUseCase';
import { get, writable } from 'svelte/store';
export type ImageInfoState = AsyncState<ImageInfoViewModel>;
export type ImageEvent = ImageUploadedEvent;
export class ImageBloc {
private readonly state = writable<ImageInfoState>({
status: StatusType.Idle,
});
constructor(private readonly uploadImageUseCase: UploadImageUseCase) {}
get subscribe() {
return this.state.subscribe;
}
async dispatch(event: ImageEvent): Promise<ImageInfoState> {
switch (event.event) {
case ImageEventType.ImageUploadedEvent:
return this.uploadImage(event.file);
}
}
private async uploadImage(file: File): Promise<ImageInfoState> {
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
let result: ImageInfoState;
try {
const imageInfo = await this.uploadImageUseCase.execute(file);
const imageInfoViewModel = ImageInfoViewModel.fromEntity(imageInfo);
result = { status: StatusType.Success, data: imageInfoViewModel };
} catch (error) {
result = { status: StatusType.Error, error: error as Error };
}
return result;
}
}
export enum ImageEventType {
ImageUploadedEvent,
}
interface ImageUploadedEvent {
event: ImageEventType.ImageUploadedEvent;
file: File;
}

View File

@ -0,0 +1,39 @@
import { AsyncState } from '$lib/common/adapter/presenter/asyncState';
import type { BaseStore } from '$lib/common/adapter/presenter/baseStore';
import { ImageInfoViewModel } from '$lib/image/adapter/presenter/imageInfoViewModel';
import type { UploadImageUseCase } from '$lib/image/application/useCase/uploadImageUseCase';
import { captureException } from '@sentry/sveltekit';
import { get, writable } from 'svelte/store';
type ImageInfoState = AsyncState<ImageInfoViewModel>;
export class ImageUploadedStore implements BaseStore<ImageInfoState, File> {
private readonly state = writable<ImageInfoState>(AsyncState.idle<ImageInfoViewModel>(null));
constructor(private readonly uploadImageUseCase: UploadImageUseCase) {}
get subscribe() {
return this.state.subscribe;
}
get trigger() {
return (file: File) => this.uploadImage(file);
}
private async uploadImage(file: File): Promise<ImageInfoState> {
this.state.set(AsyncState.loading(get(this.state).data));
let result: ImageInfoState;
try {
const imageInfo = await this.uploadImageUseCase.execute(file);
const imageInfoViewModel = ImageInfoViewModel.fromEntity(imageInfo);
result = AsyncState.success(imageInfoViewModel);
} catch (e) {
result = AsyncState.error(e, get(this.state).data);
captureException(e);
}
this.state.set(result);
return result;
}
}

View File

@ -1,3 +1,4 @@
import { HttpError } from '$lib/common/framework/web/httpError';
import { Environment } from '$lib/environment'; import { Environment } from '$lib/environment';
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService'; import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
import { ImageInfoResponseDto } from '$lib/image/adapter/gateway/imageInfoResponseDto'; import { ImageInfoResponseDto } from '$lib/image/adapter/gateway/imageInfoResponseDto';
@ -17,8 +18,9 @@ export class ImageApiServiceImpl implements ImageApiService {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`); throw new HttpError(response.status, url);
} }
const data = await response.json(); const data = await response.json();
return ImageInfoResponseDto.fromJson(data); return ImageInfoResponseDto.fromJson(data);
} }

View File

@ -1,19 +1,17 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import UploadImageDialoag from './UploadImageDialoag.svelte'; import UploadImageDialoag from '$lib/image/framework/ui/UploadImageDialoag.svelte';
import { ImageBloc, ImageEventType } from '$lib/image/adapter/presenter/imageBloc';
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore';
const imageBloc = getContext<ImageBloc>(ImageBloc.name); const store = getContext<ImageUploadedStore>(ImageUploadedStore.name);
const state = $derived($imageBloc); const state = $derived($store);
const { trigger: uploadImage } = store;
const isLoading = $derived(state.status === StatusType.Loading);
async function onUploadImageDialogSubmit(file: File) { async function onUploadImageDialogSubmit(file: File) {
const state = await imageBloc.dispatch({ event: ImageEventType.ImageUploadedEvent, file }); const state = await uploadImage(file);
if (state.status === StatusType.Success) { if (state.isSuccess()) {
const imageInfo = state.data; const imageInfo = state.data;
console.log('Image URL', imageInfo.url.href); console.log('Image URL', imageInfo.url.href);
@ -30,7 +28,7 @@
? 'The URL is copied to clipboard' ? 'The URL is copied to clipboard'
: 'The URL is printed in console', : 'The URL is printed in console',
}); });
} else if (state.status === StatusType.Error) { } else if (state.isError()) {
toast.error('Failed to upload image', { toast.error('Failed to upload image', {
description: state.error?.message ?? 'Unknown error', description: state.error?.message ?? 'Unknown error',
}); });
@ -41,7 +39,7 @@
<div class="dashboard-container mb-10"> <div class="dashboard-container mb-10">
<div class="flex flex-row items-center justify-between"> <div class="flex flex-row items-center justify-between">
<h1 class="py-16 text-5xl font-bold text-gray-800">Image</h1> <h1 class="py-16 text-5xl font-bold text-gray-800">Image</h1>
<UploadImageDialoag disabled={isLoading} onSubmit={onUploadImageDialogSubmit} /> <UploadImageDialoag disabled={state.isLoading()} onSubmit={onUploadImageDialogSubmit} />
</div> </div>
<p>Gallery is currently unavailable.</p> <p>Gallery is currently unavailable.</p>
</div> </div>

View File

@ -36,17 +36,13 @@
} }
await uploadImage(file); await uploadImage(file);
close();
files = undefined; files = undefined;
fileInputErrorMessage = null; fileInputErrorMessage = null;
}
function close() {
open = false; open = false;
} }
</script> </script>
<Dialog {open} onOpenChange={(val) => (open = val)}> <Dialog bind:open>
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Upload</DialogTrigger> <DialogTrigger class={buttonVariants({ variant: 'default' })}>Upload</DialogTrigger>
<DialogContent <DialogContent
showCloseButton={false} showCloseButton={false}
@ -57,7 +53,7 @@
<DialogTitle>Upload Image</DialogTitle> <DialogTitle>Upload Image</DialogTitle>
</DialogHeader> </DialogHeader>
<form id="upload-form" onsubmit={onSubmit}> <form id="upload-image-form" onsubmit={onSubmit}>
<Label for="file-input" class="pb-2"> <Label for="file-input" class="pb-2">
{`Image File (${imageMimeTypes.join(', ')})`} {`Image File (${imageMimeTypes.join(', ')})`}
</Label> </Label>
@ -76,8 +72,8 @@
</form> </form>
<DialogFooter class="mt-6"> <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-form" {disabled}>Submit</Button> <Button type="submit" form="upload-image-form" {disabled}>Submit</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

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 | null;
readonly publishedTime: Date | null;
private constructor(props: {
semanticId: string;
title: string;
description: string;
content: string;
labelIds: number[];
previewImageUrl: URL | null;
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: null,
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,10 @@
import type { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
import type { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; 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'; import type { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export interface PostApiService { export interface PostApiService {
getAllPosts(): Promise<PostInfoResponseDto[]>; getAllPosts(searchParams: PostListQueryDto): Promise<PostInfoResponseDto[]>;
getPost(id: string): Promise<PostResponseDto | null>; getPost(id: string): Promise<PostResponseDto | null>;
createPost(payload: CreatePostRequestDto): Promise<PostResponseDto>;
} }

View File

@ -7,7 +7,7 @@ export const PostInfoResponseSchema = z.object({
semantic_id: z.string(), semantic_id: z.string(),
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
preview_image_url: z.url(), preview_image_url: z.url().nullable(),
labels: z.array(LabelResponseSchema), labels: z.array(LabelResponseSchema),
published_time: z.iso.datetime({ offset: true }).nullable(), published_time: z.iso.datetime({ offset: true }).nullable(),
}); });
@ -17,7 +17,7 @@ export class PostInfoResponseDto {
readonly semanticId: string; readonly semanticId: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly previewImageUrl: URL; readonly previewImageUrl: URL | null;
readonly labels: readonly LabelResponseDto[]; readonly labels: readonly LabelResponseDto[];
readonly publishedTime: Date | null; readonly publishedTime: Date | null;
@ -26,7 +26,7 @@ export class PostInfoResponseDto {
semanticId: string; semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: URL; previewImageUrl: URL | null;
labels: LabelResponseDto[]; labels: LabelResponseDto[];
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {
@ -47,12 +47,17 @@ export class PostInfoResponseDto {
published_time = new Date(parsedJson.published_time); published_time = new Date(parsedJson.published_time);
} }
let preview_image_url: URL | null = null;
if (parsedJson.preview_image_url !== null) {
preview_image_url = new URL(parsedJson.preview_image_url);
}
return new PostInfoResponseDto({ return new PostInfoResponseDto({
id: parsedJson.id, id: parsedJson.id,
semanticId: parsedJson.semantic_id, semanticId: parsedJson.semantic_id,
title: parsedJson.title, title: parsedJson.title,
description: parsedJson.description, description: parsedJson.description,
previewImageUrl: new URL(parsedJson.preview_image_url), previewImageUrl: preview_image_url,
labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)), labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)),
publishedTime: published_time, publishedTime: published_time,
}); });

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,18 +1,30 @@
import { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/gateway/postRepository'; import { PostListQueryDto } from '$lib/post/adapter/gateway/postListQueryDto';
import type {
CreatePostParams,
PostRepository,
} from '$lib/post/application/gateway/postRepository';
import type { Post } from '$lib/post/domain/entity/post'; import type { Post } from '$lib/post/domain/entity/post';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class PostRepositoryImpl implements PostRepository { export class PostRepositoryImpl implements PostRepository {
constructor(private readonly postApiService: PostApiService) {} constructor(private readonly postApiService: PostApiService) {}
async getAllPosts(): Promise<PostInfo[]> { async getAllPosts(showUnpublished: boolean): Promise<PostInfo[]> {
const dtos = await this.postApiService.getAllPosts(); const queryDto = new PostListQueryDto({ showUnpublished });
return dtos.map((dto) => dto.toEntity()); const responseDtos = await this.postApiService.getAllPosts(queryDto);
return responseDtos.map((dto) => dto.toEntity());
} }
async getPost(id: string): Promise<Post | null> { async getPost(id: string): Promise<Post | null> {
const dto = await this.postApiService.getPost(id); const dto = await this.postApiService.getPost(id);
return dto?.toEntity() ?? null; 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

@ -1,62 +0,0 @@
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: string): 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,
}
interface PostLoadedEvent {
event: PostEventType.PostLoadedEvent;
id: string;
}

View File

@ -0,0 +1,47 @@
import { AsyncState } from '$lib/common/adapter/presenter/asyncState';
import type { BaseStore } from '$lib/common/adapter/presenter/baseStore';
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 { captureException } from '@sentry/sveltekit';
import { get, writable } from 'svelte/store';
type PostState = AsyncState<PostViewModel>;
export class PostCreatedStore implements BaseStore<PostState, CreatePostParams> {
private readonly state = writable<PostState>(AsyncState.idle<PostViewModel>(null));
constructor(
private readonly createPostUseCase: CreatePostUseCase,
initialData?: PostViewModel
) {
if (initialData) {
this.state.set(AsyncState.idle(initialData));
}
}
get subscribe() {
return this.state.subscribe;
}
get trigger() {
return (params: CreatePostParams) => this.createPost(params);
}
private async createPost(params: CreatePostParams): Promise<PostState> {
this.state.set(AsyncState.loading(get(this.state).data));
let result: PostState;
try {
const post = await this.createPostUseCase.execute(params);
const postViewModel = PostViewModel.fromEntity(post);
result = AsyncState.success(postViewModel);
} catch (e) {
result = AsyncState.error(e, get(this.state).data);
captureException(e);
}
this.state.set(result);
return result;
}
}

View File

@ -9,7 +9,7 @@ export class PostInfoViewModel {
readonly semanticId: string; readonly semanticId: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly previewImageUrl: URL; readonly previewImageUrl: URL | null;
readonly labels: readonly LabelViewModel[]; readonly labels: readonly LabelViewModel[];
readonly publishedTime: Date | null; readonly publishedTime: Date | null;
@ -18,7 +18,7 @@ export class PostInfoViewModel {
semanticId: string; semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: URL; previewImageUrl: URL | null;
labels: readonly LabelViewModel[]; labels: readonly LabelViewModel[];
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {
@ -49,12 +49,17 @@ export class PostInfoViewModel {
publishedTime = new Date(props.publishedTime); publishedTime = new Date(props.publishedTime);
} }
let previewImageUrl: URL | null = null;
if (props.previewImageUrl) {
previewImageUrl = new URL(props.previewImageUrl);
}
return new PostInfoViewModel({ return new PostInfoViewModel({
id: props.id, id: props.id,
semanticId: props.semanticId, semanticId: props.semanticId,
title: props.title, title: props.title,
description: props.description, description: props.description,
previewImageUrl: new URL(props.previewImageUrl), previewImageUrl: previewImageUrl,
labels: props.labels.map((label) => LabelViewModel.rehydrate(label)), labels: props.labels.map((label) => LabelViewModel.rehydrate(label)),
publishedTime: publishedTime, publishedTime: publishedTime,
}); });
@ -74,7 +79,7 @@ export class PostInfoViewModel {
semanticId: this.semanticId, semanticId: this.semanticId,
title: this.title, title: this.title,
description: this.description, description: this.description,
previewImageUrl: this.previewImageUrl.href, previewImageUrl: this.previewImageUrl?.href ?? null,
labels: this.labels.map((label) => label.dehydrate()), labels: this.labels.map((label) => label.dehydrate()),
publishedTime: this.publishedTime?.getTime() ?? null, publishedTime: this.publishedTime?.getTime() ?? null,
}; };
@ -86,7 +91,7 @@ export interface DehydratedPostInfoProps {
semanticId: string; semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: string; previewImageUrl: string | null;
labels: DehydratedLabelProps[]; labels: DehydratedLabelProps[];
publishedTime: number | null; publishedTime: number | null;
} }

View File

@ -1,55 +0,0 @@
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import type { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { get, writable } from 'svelte/store';
export type PostListState = AsyncState<readonly PostInfoViewModel[]>;
export type PostListEvent = PostListLoadedEvent;
export class PostListBloc {
private readonly state = writable<PostListState>({
status: StatusType.Idle,
});
constructor(
private readonly getAllPostsUseCase: GetAllPostsUseCase,
initialData?: readonly PostInfoViewModel[]
) {
this.state.set({
status: StatusType.Idle,
data: initialData,
});
}
get subscribe() {
return this.state.subscribe;
}
async dispatch(event: PostListEvent): Promise<PostListState> {
switch (event.event) {
case PostListEventType.PostListLoadedEvent:
return this.loadPosts();
}
}
private async loadPosts(): Promise<PostListState> {
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
const posts = await this.getAllPostsUseCase.execute();
const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post));
const result: PostListState = {
status: StatusType.Success,
data: postViewModels,
};
this.state.set(result);
return result;
}
}
export enum PostListEventType {
PostListLoadedEvent,
}
export interface PostListLoadedEvent {
event: PostListEventType.PostListLoadedEvent;
}

View File

@ -0,0 +1,51 @@
import { AsyncState } from '$lib/common/adapter/presenter/asyncState';
import type { BaseStore } from '$lib/common/adapter/presenter/baseStore';
import { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
import type { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { captureException } from '@sentry/sveltekit';
import { get, writable } from 'svelte/store';
type PostState = AsyncState<PostViewModel>;
export class PostLoadedStore implements BaseStore<PostState, string> {
private readonly state = writable<PostState>(AsyncState.idle<PostViewModel>(null));
constructor(
private readonly getPostUseCase: GetPostUseCase,
initialData?: PostViewModel
) {
if (initialData) {
this.state.set(AsyncState.idle(initialData));
}
}
get subscribe() {
return this.state.subscribe;
}
get trigger() {
return (id: string) => this.loadPost(id);
}
private async loadPost(id: string): Promise<PostState> {
this.state.set(AsyncState.loading(get(this.state).data));
let result: PostState;
try {
const post = await this.getPostUseCase.execute(id);
if (!post) {
result = AsyncState.error(new Error('Post not found'), get(this.state).data);
this.state.set(result);
return result;
}
const postViewModel = PostViewModel.fromEntity(post);
result = AsyncState.success(postViewModel);
} catch (e) {
result = AsyncState.error(e, get(this.state).data);
captureException(e);
}
this.state.set(result);
return result;
}
}

View File

@ -0,0 +1,46 @@
import { AsyncState } from '$lib/common/adapter/presenter/asyncState';
import type { BaseStore } from '$lib/common/adapter/presenter/baseStore';
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import type { GetAllPostsUseCase } from '$lib/post/application/useCase/getAllPostsUseCase';
import { captureException } from '@sentry/sveltekit';
import { get, writable } from 'svelte/store';
type PostListState = AsyncState<readonly PostInfoViewModel[]>;
export class PostsListedStore implements BaseStore<PostListState, { showUnpublished: boolean }> {
private readonly state = writable<PostListState>(AsyncState.idle([]));
constructor(
private readonly getAllPostsUseCase: GetAllPostsUseCase,
initialData?: readonly PostInfoViewModel[]
) {
if (initialData) {
this.state.set(AsyncState.idle(initialData));
}
}
get subscribe() {
return this.state.subscribe;
}
get trigger() {
return (options?: { showUnpublished: boolean }) => this.loadPosts(options?.showUnpublished);
}
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(showUnpublished);
const postViewModels = posts.map((post) => PostInfoViewModel.fromEntity(post));
result = AsyncState.success(postViewModels);
} catch (e) {
result = AsyncState.error(e, get(this.state).data);
captureException(e);
}
this.state.set(result);
return result;
}
}

View File

@ -2,6 +2,12 @@ import type { Post } from '$lib/post/domain/entity/post';
import type { PostInfo } from '$lib/post/domain/entity/postInfo'; import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export interface PostRepository { export interface PostRepository {
getAllPosts(): Promise<PostInfo[]>; getAllPosts(showUnpublished: boolean): Promise<PostInfo[]>;
getPost(id: string): Promise<Post | null>; 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

@ -4,7 +4,7 @@ import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class GetAllPostsUseCase { export class GetAllPostsUseCase {
constructor(private readonly postRepository: PostRepository) {} constructor(private readonly postRepository: PostRepository) {}
execute(): Promise<PostInfo[]> { execute(showUnpublished: boolean = false): Promise<PostInfo[]> {
return this.postRepository.getAllPosts(); return this.postRepository.getAllPosts(showUnpublished);
} }
} }

View File

@ -5,7 +5,7 @@ export class PostInfo {
readonly semanticId: string; readonly semanticId: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly previewImageUrl: URL; readonly previewImageUrl: URL | null;
readonly labels: readonly Label[]; readonly labels: readonly Label[];
readonly publishedTime: Date | null; readonly publishedTime: Date | null;
@ -14,7 +14,7 @@ export class PostInfo {
semanticId: string; semanticId: string;
title: string; title: string;
description: string; description: string;
previewImageUrl: URL; previewImageUrl: URL | null;
labels: readonly Label[]; labels: readonly Label[];
publishedTime: Date | null; publishedTime: Date | null;
}) { }) {

View File

@ -1,22 +1,27 @@
import { HttpError } from '$lib/common/framework/web/httpError';
import { HttpStatusCode } from '$lib/common/framework/web/httpStatusCode';
import { Environment } from '$lib/environment'; import { Environment } from '$lib/environment';
import type { CreatePostRequestDto } from '$lib/post/adapter/gateway/creatPostRequestDto';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseDto'; 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'; import { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export class PostApiServiceImpl implements PostApiService { export class PostApiServiceImpl implements PostApiService {
constructor(private readonly fetchFn: typeof fetch) {} 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); const url = new URL('post', Environment.API_BASE_URL);
url.search = searchParams.toSearchParams().toString();
const response = await this.fetchFn(url); const response = await this.fetchFn(url);
if (!response.ok) { if (!response.ok) {
return []; throw new HttpError(response.status, url);
} }
const json = await response.json(); const data = await response.json();
return json.map(PostInfoResponseDto.fromJson); return data.map(PostInfoResponseDto.fromJson);
} }
async getPost(id: string): Promise<PostResponseDto | null> { async getPost(id: string): Promise<PostResponseDto | null> {
@ -24,11 +29,32 @@ export class PostApiServiceImpl implements PostApiService {
const response = await this.fetchFn(url); const response = await this.fetchFn(url);
if (!response.ok) { if (response.status === HttpStatusCode.NOT_FOUND) {
return null; return null;
} }
const json = await response.json(); if (!response.ok) {
return PostResponseDto.fromJson(json); throw new HttpError(response.status, url);
}
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

@ -1,40 +1,42 @@
<script lang="ts"> <script lang="ts">
import { PostBloc, PostEventType } from '$lib/post/adapter/presenter/postBloc';
import PostContentHeader from '$lib/post/framework/ui/PostContentHeader.svelte'; import PostContentHeader from '$lib/post/framework/ui/PostContentHeader.svelte';
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
import markdownit from 'markdown-it'; import markdownit from 'markdown-it';
import SafeHtml from '$lib/common/framework/ui/SafeHtml.svelte'; import SafeHtml from '$lib/common/framework/ui/SafeHtml.svelte';
import generateTitle from '$lib/common/framework/ui/generateTitle'; import generateTitle from '$lib/common/framework/ui/generateTitle';
import StructuredData from '$lib/post/framework/ui/StructuredData.svelte'; import StructuredData from '$lib/post/framework/ui/StructuredData.svelte';
import { PostLoadedStore } from '$lib/post/adapter/presenter/postLoadedStore';
const { id }: { id: string } = $props(); const { id }: { id: string } = $props();
const postBloc = getContext<PostBloc>(PostBloc.name); const store = getContext<PostLoadedStore>(PostLoadedStore.name);
const state = $derived($postBloc); const state = $derived($store);
const { trigger: loadPost } = store;
const md = markdownit(); const md = markdownit();
const parsedContent = $derived(state.data?.content ? md.render(state.data.content) : ''); const parsedContent = $derived(state.data?.content ? md.render(state.data.content) : '');
const postInfo = $derived(state.data?.info);
onMount(() => postBloc.dispatch({ event: PostEventType.PostLoadedEvent, id: id })); onMount(() => loadPost(id));
</script> </script>
<svelte:head> <svelte:head>
<title>{generateTitle(state.data?.info.title)}</title> <title>{generateTitle(postInfo?.title)}</title>
{#if state.data} {#if postInfo}
<meta name="description" content={state.data.info.description} /> <meta name="description" content={postInfo.description} />
{#if state.data.info.isPublished} {#if postInfo.isPublished}
<StructuredData <StructuredData
headline={state.data.info.title} headline={postInfo.title}
description={state.data.info.description} description={postInfo.description}
datePublished={state.data.info.publishedTime!} datePublished={postInfo.publishedTime!}
image={state.data.info.previewImageUrl} image={postInfo.previewImageUrl}
/> />
{/if} {/if}
{/if} {/if}
</svelte:head> </svelte:head>
<article class="content-container prose pb-10 prose-gray"> <article class="content-container prose pb-10 prose-gray">
{#if state.data} {#if postInfo}
<PostContentHeader postInfo={state.data.info} /> <PostContentHeader {postInfo} />
<div class="max-w-3xl"> <div class="max-w-3xl">
<hr /> <hr />
<SafeHtml html={parsedContent} /> <SafeHtml html={parsedContent} />

View File

@ -0,0 +1,71 @@
<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 PostLabel from '$lib/post/framework/ui/PostLabel.svelte';
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
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);
if (state.isSuccess()) {
toast.success(`Post created successfully with ID: ${state.data.id}`);
} else if (state.isError()) {
toast.error('Failed to create post', {
description: state.error.message,
});
}
}
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={postCreatedState.isLoading()} onSubmit={onCreatePostDialogSubmit} />
</div>
<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

@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import generateTitle from '$lib/common/framework/ui/generateTitle'; import generateTitle from '$lib/common/framework/ui/generateTitle';
import { PostListBloc, PostListEventType } from '$lib/post/adapter/presenter/postListBloc'; import { PostsListedStore } from '$lib/post/adapter/presenter/postsListedStore';
import PostPreview from '$lib/post/framework/ui/PostPreview.svelte'; import PostPreview from '$lib/post/framework/ui/PostPreview.svelte';
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
const postListBloc = getContext<PostListBloc>(PostListBloc.name); const store = getContext<PostsListedStore>(PostsListedStore.name);
const state = $derived($postListBloc); const state = $derived($store);
const { trigger: loadPosts } = store;
onMount(() => postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent })); onMount(() => loadPosts());
</script> </script>
<svelte:head> <svelte:head>

View File

@ -7,11 +7,11 @@
let isImageLoading = $state(true); let isImageLoading = $state(true);
let isImageError = $state(false); let isImageError = $state(false);
function handleImageLoad() { function onImageLoad() {
isImageLoading = false; isImageLoading = false;
} }
function handleImageError() { function onImageError() {
isImageLoading = false; isImageLoading = false;
isImageError = true; isImageError = true;
} }
@ -27,10 +27,10 @@
class="rounded-2xl object-cover transition-opacity duration-300 class="rounded-2xl object-cover transition-opacity duration-300
{isImageLoading ? 'opacity-0' : 'opacity-100'} {isImageLoading ? 'opacity-0' : 'opacity-100'}
{isImageError ? 'hidden' : ''}" {isImageError ? 'hidden' : ''}"
src={postInfo.previewImageUrl.href} src={postInfo.previewImageUrl?.href}
alt={postInfo.title} alt={postInfo.title}
onload={handleImageLoad} onload={onImageLoad}
onerror={handleImageError} onerror={onImageError}
/> />
{#if isImageLoading || isImageError} {#if isImageLoading || isImageError}
<div class="absolute inset-0 flex items-center justify-center bg-gray-200"></div> <div class="absolute inset-0 flex items-center justify-center bg-gray-200"></div>

View File

@ -10,7 +10,7 @@
headline: string; headline: string;
description: string; description: string;
datePublished: Date; datePublished: Date;
image: URL; image: URL | null;
} = $props(); } = $props();
const structuredData = $derived({ const structuredData = $derived({
@ -19,7 +19,7 @@
headline: headline, headline: headline,
description: description, description: description,
datePublished: datePublished.toISOString(), datePublished: datePublished.toISOString(),
image: image.href, image: image?.href,
}); });
const jsonLdScript = $derived( const jsonLdScript = $derived(

View File

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

View File

@ -5,6 +5,11 @@
import '../app.css'; import '../app.css';
import '@fortawesome/fontawesome-free/css/all.min.css'; import '@fortawesome/fontawesome-free/css/all.min.css';
import { Toaster } from '$lib/common/framework/components/ui/sonner'; import { Toaster } from '$lib/common/framework/components/ui/sonner';
import { Container } from '$lib/container';
import { setContext } from 'svelte';
const container = new Container(fetch);
setContext(Container.name, container);
</script> </script>
<GoogleAnalytics /> <GoogleAnalytics />

View File

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

View File

@ -1,52 +1,34 @@
<script lang="ts"> <script lang="ts">
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService'; import { getContext, onMount, setContext } from 'svelte';
import { AuthRepositoryImpl } from '$lib/auth/adapter/gateway/authRepositoryImpl';
import { AuthBloc, AuthEventType } from '$lib/auth/adapter/presenter/authBloc';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl';
import { onMount, setContext } from 'svelte';
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
import ErrorPage from '$lib/common/framework/ui/ErrorPage.svelte'; import ErrorPage from '$lib/common/framework/ui/ErrorPage.svelte';
import DashboardNavbar from '$lib/dashboard/framework/ui/DashboardNavbar.svelte'; import DashboardNavbar from '$lib/dashboard/framework/ui/DashboardNavbar.svelte';
import type { DashboardLink } from '$lib/dashboard/framework/ui/dashboardLink'; import type { DashboardLink } from '$lib/dashboard/framework/ui/dashboardLink';
import { Container } from '$lib/container';
import { AuthLoadedStore } from '$lib/auth/adapter/presenter/authLoadedStore';
const { children }: LayoutProps = $props(); const { children }: LayoutProps = $props();
const container = getContext<Container>(Container.name);
const store = container.createAuthLoadedStore();
setContext(AuthLoadedStore.name, store);
const authApiService: AuthApiService = new AuthApiServiceImpl(fetch); const state = $derived($store);
const authRepository: AuthRepository = new AuthRepositoryImpl(authApiService); const { trigger: loadAuthentication } = store;
const getcurrentUserUseCase = new GetCurrentUserUseCase(authRepository);
const authBloc = new AuthBloc(getcurrentUserUseCase);
setContext(AuthBloc.name, authBloc); const isAuthenticated = $derived(state.isSuccess() && state.data.isAuthenticated);
onMount(() => authBloc.dispatch({ event: AuthEventType.CurrentUserLoadedEvent }));
const authState = $derived($authBloc);
const isLoading = $derived(
authState.status === StatusType.Loading || authState.status === StatusType.Idle
);
const hasError = $derived.by(() => {
if (authState.status === StatusType.Error) {
return true;
}
if (authState.status === StatusType.Success && !authState.data.isAuthenticated) {
return true;
}
return false;
});
const links: DashboardLink[] = [ const links: DashboardLink[] = [
{ label: 'Post', href: '/dashboard/post' }, { label: 'Post', href: '/dashboard/post' },
{ label: 'Label', href: '/dashboard/label' }, { label: 'Label', href: '/dashboard/label' },
{ label: 'Image', href: '/dashboard/image' }, { label: 'Image', href: '/dashboard/image' },
]; ];
onMount(() => loadAuthentication());
</script> </script>
{#if isLoading} {#if state.isIdle() || state.isLoading()}
<div></div> <div></div>
{:else if hasError} {:else if !isAuthenticated}
<ErrorPage /> <ErrorPage />
{:else} {:else}
<div class="grid min-h-content-height grid-cols-[auto_1fr]"> <div class="grid min-h-content-height grid-cols-[auto_1fr]">

View File

@ -1,19 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService'; import { Container } from '$lib/container';
import { ImageRepositoryImpl } from '$lib/image/adapter/gateway/imageRepositoryImpl'; import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore';
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 ImageManagementPage from '$lib/image/framework/ui/ImageManagementPage.svelte';
import { setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
const imageApiService: ImageApiService = new ImageApiServiceImpl(fetch); const container = getContext<Container>(Container.name);
const imageRepository: ImageRepository = new ImageRepositoryImpl(imageApiService); const store = container.createImageUploadedStore();
const uploadImageUseCase = new UploadImageUseCase(imageRepository); setContext(ImageUploadedStore.name, store);
const imageBloc = new ImageBloc(uploadImageUseCase);
setContext(ImageBloc.name, imageBloc);
</script> </script>
<ImageManagementPage /> <ImageManagementPage />

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 +1,21 @@
<div>Post</div> <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 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 />

View File

@ -1,10 +1,11 @@
import { PostListEventType } from '$lib/post/adapter/presenter/postListBloc';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
const { postListBloc } = locals; const { container } = locals;
const store = container.createPostsListedStore();
const { trigger: loadPosts } = store;
const state = await postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent }); const state = await loadPosts();
return { return {
dehydratedData: state.data?.map((post) => post.dehydrate()), dehydratedData: state.data?.map((post) => post.dehydrate()),

View File

@ -1,25 +1,17 @@
<script lang="ts"> <script lang="ts">
import { PostRepositoryImpl } from '$lib/post/adapter/gateway/postRepositoryImpl'; import { getContext, setContext } from 'svelte';
import { PostListBloc } from '$lib/post/adapter/presenter/postListBloc';
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'; import type { PageProps } from './$types';
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel'; import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import PostOverallPage from '$lib/post/framework/ui/PostOverallPage.svelte'; import PostOverallPage from '$lib/post/framework/ui/PostOverallPage.svelte';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; import { Container } from '$lib/container';
import type { PostRepository } from '$lib/post/application/gateway/postRepository'; import { PostsListedStore } from '$lib/post/adapter/presenter/postsListedStore';
let { data }: PageProps = $props(); const { data }: PageProps = $props();
const container = getContext<Container>(Container.name);
const initialData = data.dehydratedData?.map((post) => PostInfoViewModel.rehydrate(post)); const initialData = data.dehydratedData?.map((post) => PostInfoViewModel.rehydrate(post));
const store = container.createPostsListedStore(initialData);
const postApiService: PostApiService = new PostApiServiceImpl(fetch); setContext(PostsListedStore.name, store);
const postRepository: PostRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const postListBloc = new PostListBloc(getAllPostsUseCase, initialData);
setContext(PostListBloc.name, postListBloc);
</script> </script>
<PostOverallPage /> <PostOverallPage />

View File

@ -1,14 +1,12 @@
import { PostEventType } from '$lib/post/adapter/presenter/postBloc';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => { export const load: PageServerLoad = async ({ locals, params }) => {
const { postBloc } = locals; const { container } = locals;
const store = container.createPostLoadedStore();
const { trigger: loadPost } = store;
const state = await postBloc.dispatch({ const state = await loadPost(params.id);
event: PostEventType.PostLoadedEvent,
id: params.id,
});
if (!state.data) { if (!state.data) {
error(404, { message: 'Post not found' }); error(404, { message: 'Post not found' });
} }

View File

@ -1,26 +1,18 @@
<script lang="ts"> <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 { PostViewModel } from '$lib/post/adapter/presenter/postViewModel';
import { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase'; import { getContext, setContext } from 'svelte';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
import { setContext } from 'svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import PostContentPage from '$lib/post/framework/ui/PostContentPage.svelte'; import PostContentPage from '$lib/post/framework/ui/PostContentPage.svelte';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService'; import { Container } from '$lib/container';
import type { PostRepository } from '$lib/post/application/gateway/postRepository'; import { PostLoadedStore } from '$lib/post/adapter/presenter/postLoadedStore';
const { data, params }: PageProps = $props(); const { data, params }: PageProps = $props();
const { id } = params; const { id } = params;
const container = getContext<Container>(Container.name);
const initialData = PostViewModel.rehydrate(data.dehydratedData!); const initialData = PostViewModel.rehydrate(data.dehydratedData!);
const store = container.createPostLoadedStore(initialData);
const postApiService: PostApiService = new PostApiServiceImpl(fetch); setContext(PostLoadedStore.name, store);
const postRepository: PostRepository = new PostRepositoryImpl(postApiService);
const getPostUseCase = new GetPostUseCase(postRepository);
const postBloc = new PostBloc(getPostUseCase, initialData);
setContext(PostBloc.name, postBloc);
</script> </script>
<PostContentPage {id} /> <PostContentPage {id} />