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

Merged
squid merged 10 commits from BLOG-126_post_management into main 2025-10-15 04:21:15 +08:00
67 changed files with 1177 additions and 441 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ pub struct PostWithLabelRecord {
pub semantic_id: String,
pub title: String,
pub description: String,
pub preview_image_url: String,
pub preview_image_url: Option<String>,
pub content: String,
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 Locals {
authBloc: import('$lib/auth/adapter/presenter/authBloc').AuthBloc;
postListBloc: import('$lib/post/adapter/presenter/postListBloc').PostListBloc;
postBloc: import('$lib/post/adapter/presenter/postBloc').PostBloc;
container: import('$lib/container').Container;
}
// interface PageData {}

View File

@ -1,21 +1,8 @@
import { sequence } from '@sveltejs/kit/hooks';
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 { Environment } from '$lib/environment';
import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl';
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';
import { Container } from '$lib/container';
Sentry.init({
dsn: Environment.SENTRY_DSN,
@ -24,18 +11,7 @@ Sentry.init({
});
export const handle: Handle = sequence(Sentry.sentryHandle(), ({ event, resolve }) => {
const authApiService: AuthApiService = new AuthApiServiceImpl(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);
event.locals.container ??= new Container(event.fetch);
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 { 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';
export class AuthApiServiceImpl implements AuthApiService {
@ -10,10 +12,14 @@ export class AuthApiServiceImpl implements AuthApiService {
const response = await this.fetchFn(url);
if (!response.ok) {
if (response.status === HttpStatusCode.UNAUTHORIZED) {
return null;
}
if (!response.ok) {
throw new HttpError(response.status, url);
}
const json = await response.json();
return UserResponseDto.fromJson(json);
}

View File

@ -1,29 +1,110 @@
export enum StatusType {
Idle,
Loading,
Success,
Error,
enum AsyncStateStatus {
Idle = 'idle',
Loading = 'loading',
Success = 'success',
Error = 'error',
}
export interface IdleState<T> {
status: StatusType.Idle;
data?: T;
export abstract class AsyncState<T> {
abstract readonly status: AsyncStateStatus;
abstract readonly data: T | null;
abstract readonly error: Error | null;
static idle<T>(data: T | null): IdleState<T> {
return new IdleState(data);
}
static loading<T>(data: T | null): LoadingState<T> {
return new LoadingState(data);
}
static success<T>(data: T): SuccessState<T> {
return new SuccessState(data);
}
static error<T>(error: unknown, data: T | null): ErrorState<T> {
const errorInstance = error instanceof Error ? error : new Error(String(error));
return new ErrorState(errorInstance, data);
}
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;
}
}
export interface LoadingState<T> {
status: StatusType.Loading;
data?: T;
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);
}
}
export interface SuccessState<T> {
status: StatusType.Success;
data: T;
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);
}
}
export interface ErrorState<T> {
status: StatusType.Error;
data?: T;
error: Error;
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);
}
}
export type AsyncState<T> = IdleState<T> | LoadingState<T> | SuccessState<T> | ErrorState<T>;
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>
import MottoAnimatedMark from './MottoAnimatedMark.svelte';
import MottoAnimatedMark from '$lib/home/framework/ui/MottoAnimatedMark.svelte';
</script>
<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 type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
import { ImageInfoResponseDto } from '$lib/image/adapter/gateway/imageInfoResponseDto';
@ -17,8 +18,9 @@ export class ImageApiServiceImpl implements ImageApiService {
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
throw new HttpError(response.status, url);
}
const data = await response.json();
return ImageInfoResponseDto.fromJson(data);
}

View File

@ -1,19 +1,17 @@
<script lang="ts">
import { getContext } from 'svelte';
import UploadImageDialoag from './UploadImageDialoag.svelte';
import { ImageBloc, ImageEventType } from '$lib/image/adapter/presenter/imageBloc';
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
import UploadImageDialoag from '$lib/image/framework/ui/UploadImageDialoag.svelte';
import { toast } from 'svelte-sonner';
import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore';
const imageBloc = getContext<ImageBloc>(ImageBloc.name);
const state = $derived($imageBloc);
const isLoading = $derived(state.status === StatusType.Loading);
const store = getContext<ImageUploadedStore>(ImageUploadedStore.name);
const state = $derived($store);
const { trigger: uploadImage } = store;
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;
console.log('Image URL', imageInfo.url.href);
@ -30,7 +28,7 @@
? 'The URL is copied to clipboard'
: 'The URL is printed in console',
});
} else if (state.status === StatusType.Error) {
} else if (state.isError()) {
toast.error('Failed to upload image', {
description: state.error?.message ?? 'Unknown error',
});
@ -41,7 +39,7 @@
<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">Image</h1>
<UploadImageDialoag disabled={isLoading} onSubmit={onUploadImageDialogSubmit} />
<UploadImageDialoag disabled={state.isLoading()} onSubmit={onUploadImageDialogSubmit} />
</div>
<p>Gallery is currently unavailable.</p>
</div>

View File

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

@ -7,7 +7,7 @@ export const PostInfoResponseSchema = z.object({
semantic_id: z.string(),
title: z.string(),
description: z.string(),
preview_image_url: z.url(),
preview_image_url: z.url().nullable(),
labels: z.array(LabelResponseSchema),
published_time: z.iso.datetime({ offset: true }).nullable(),
});
@ -17,7 +17,7 @@ export class PostInfoResponseDto {
readonly semanticId: string;
readonly title: string;
readonly description: string;
readonly previewImageUrl: URL;
readonly previewImageUrl: URL | null;
readonly labels: readonly LabelResponseDto[];
readonly publishedTime: Date | null;
@ -26,7 +26,7 @@ export class PostInfoResponseDto {
semanticId: string;
title: string;
description: string;
previewImageUrl: URL;
previewImageUrl: URL | null;
labels: LabelResponseDto[];
publishedTime: Date | null;
}) {
@ -47,12 +47,17 @@ export class PostInfoResponseDto {
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({
id: parsedJson.id,
semanticId: parsedJson.semantic_id,
title: parsedJson.title,
description: parsedJson.description,
previewImageUrl: new URL(parsedJson.preview_image_url),
previewImageUrl: preview_image_url,
labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)),
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 { 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 { 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> {
const dto = await this.postApiService.getPost(id);
return dto?.toEntity() ?? null;
}
async createPost(params: CreatePostParams): Promise<Post> {
const requestDto = CreatePostRequestDto.fromParams(params);
const responseDto = await this.postApiService.createPost(requestDto);
return responseDto.toEntity();
}
}

View File

@ -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 title: string;
readonly description: string;
readonly previewImageUrl: URL;
readonly previewImageUrl: URL | null;
readonly labels: readonly LabelViewModel[];
readonly publishedTime: Date | null;
@ -18,7 +18,7 @@ export class PostInfoViewModel {
semanticId: string;
title: string;
description: string;
previewImageUrl: URL;
previewImageUrl: URL | null;
labels: readonly LabelViewModel[];
publishedTime: Date | null;
}) {
@ -49,12 +49,17 @@ export class PostInfoViewModel {
publishedTime = new Date(props.publishedTime);
}
let previewImageUrl: URL | null = null;
if (props.previewImageUrl) {
previewImageUrl = new URL(props.previewImageUrl);
}
return new PostInfoViewModel({
id: props.id,
semanticId: props.semanticId,
title: props.title,
description: props.description,
previewImageUrl: new URL(props.previewImageUrl),
previewImageUrl: previewImageUrl,
labels: props.labels.map((label) => LabelViewModel.rehydrate(label)),
publishedTime: publishedTime,
});
@ -74,7 +79,7 @@ export class PostInfoViewModel {
semanticId: this.semanticId,
title: this.title,
description: this.description,
previewImageUrl: this.previewImageUrl.href,
previewImageUrl: this.previewImageUrl?.href ?? null,
labels: this.labels.map((label) => label.dehydrate()),
publishedTime: this.publishedTime?.getTime() ?? null,
};
@ -86,7 +91,7 @@ export interface DehydratedPostInfoProps {
semanticId: string;
title: string;
description: string;
previewImageUrl: string;
previewImageUrl: string | null;
labels: DehydratedLabelProps[];
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';
export interface PostRepository {
getAllPosts(): Promise<PostInfo[]>;
getAllPosts(showUnpublished: boolean): Promise<PostInfo[]>;
getPost(id: string): Promise<Post | null>;
createPost(params: CreatePostParams): Promise<Post>;
}
export interface CreatePostParams {
semanticId: string;
title: string;
}

View File

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

View File

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

@ -5,7 +5,7 @@ export class PostInfo {
readonly semanticId: string;
readonly title: string;
readonly description: string;
readonly previewImageUrl: URL;
readonly previewImageUrl: URL | null;
readonly labels: readonly Label[];
readonly publishedTime: Date | null;
@ -14,7 +14,7 @@ export class PostInfo {
semanticId: string;
title: string;
description: string;
previewImageUrl: URL;
previewImageUrl: URL | null;
labels: readonly Label[];
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 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);
if (!response.ok) {
return [];
throw new HttpError(response.status, url);
}
const json = await response.json();
return json.map(PostInfoResponseDto.fromJson);
const data = await response.json();
return data.map(PostInfoResponseDto.fromJson);
}
async getPost(id: string): Promise<PostResponseDto | null> {
@ -24,11 +29,32 @@ export class PostApiServiceImpl implements PostApiService {
const response = await this.fetchFn(url);
if (!response.ok) {
if (response.status === HttpStatusCode.NOT_FOUND) {
return null;
}
const json = await response.json();
return PostResponseDto.fromJson(json);
if (!response.ok) {
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">
import { PostBloc, PostEventType } from '$lib/post/adapter/presenter/postBloc';
import PostContentHeader from '$lib/post/framework/ui/PostContentHeader.svelte';
import { getContext, onMount } from 'svelte';
import markdownit from 'markdown-it';
import SafeHtml from '$lib/common/framework/ui/SafeHtml.svelte';
import generateTitle from '$lib/common/framework/ui/generateTitle';
import StructuredData from '$lib/post/framework/ui/StructuredData.svelte';
import { PostLoadedStore } from '$lib/post/adapter/presenter/postLoadedStore';
const { id }: { id: string } = $props();
const postBloc = getContext<PostBloc>(PostBloc.name);
const state = $derived($postBloc);
const store = getContext<PostLoadedStore>(PostLoadedStore.name);
const state = $derived($store);
const { trigger: loadPost } = store;
const md = markdownit();
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>
<svelte:head>
<title>{generateTitle(state.data?.info.title)}</title>
{#if state.data}
<meta name="description" content={state.data.info.description} />
{#if state.data.info.isPublished}
<title>{generateTitle(postInfo?.title)}</title>
{#if postInfo}
<meta name="description" content={postInfo.description} />
{#if postInfo.isPublished}
<StructuredData
headline={state.data.info.title}
description={state.data.info.description}
datePublished={state.data.info.publishedTime!}
image={state.data.info.previewImageUrl}
headline={postInfo.title}
description={postInfo.description}
datePublished={postInfo.publishedTime!}
image={postInfo.previewImageUrl}
/>
{/if}
{/if}
</svelte:head>
<article class="content-container prose pb-10 prose-gray">
{#if state.data}
<PostContentHeader postInfo={state.data.info} />
{#if postInfo}
<PostContentHeader {postInfo} />
<div class="max-w-3xl">
<hr />
<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">
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 { getContext, onMount } from 'svelte';
const postListBloc = getContext<PostListBloc>(PostListBloc.name);
const state = $derived($postListBloc);
const store = getContext<PostsListedStore>(PostsListedStore.name);
const state = $derived($store);
const { trigger: loadPosts } = store;
onMount(() => postListBloc.dispatch({ event: PostListEventType.PostListLoadedEvent }));
onMount(() => loadPosts());
</script>
<svelte:head>

View File

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

View File

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

View File

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

View File

@ -5,6 +5,11 @@
import '../app.css';
import '@fortawesome/fontawesome-free/css/all.min.css';
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>
<GoogleAnalytics />

View File

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

View File

@ -1,52 +1,34 @@
<script lang="ts">
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
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 { getContext, onMount, setContext } from 'svelte';
import type { LayoutProps } from './$types';
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
import ErrorPage from '$lib/common/framework/ui/ErrorPage.svelte';
import DashboardNavbar from '$lib/dashboard/framework/ui/DashboardNavbar.svelte';
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 container = getContext<Container>(Container.name);
const store = container.createAuthLoadedStore();
setContext(AuthLoadedStore.name, store);
const authApiService: AuthApiService = new AuthApiServiceImpl(fetch);
const authRepository: AuthRepository = new AuthRepositoryImpl(authApiService);
const getcurrentUserUseCase = new GetCurrentUserUseCase(authRepository);
const authBloc = new AuthBloc(getcurrentUserUseCase);
const state = $derived($store);
const { trigger: loadAuthentication } = store;
setContext(AuthBloc.name, authBloc);
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 isAuthenticated = $derived(state.isSuccess() && state.data.isAuthenticated);
const links: DashboardLink[] = [
{ label: 'Post', href: '/dashboard/post' },
{ label: 'Label', href: '/dashboard/label' },
{ label: 'Image', href: '/dashboard/image' },
];
onMount(() => loadAuthentication());
</script>
{#if isLoading}
{#if state.isIdle() || state.isLoading()}
<div></div>
{:else if hasError}
{:else if !isAuthenticated}
<ErrorPage />
{:else}
<div class="grid min-h-content-height grid-cols-[auto_1fr]">

View File

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

View File

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

View File

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

View File

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