BLOG-126 Post management (list and create) #139
@ -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>,
|
||||||
|
@ -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>,
|
||||||
|
@ -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>,
|
||||||
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
@ -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>>,
|
||||||
}
|
}
|
||||||
|
@ -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>,
|
||||||
|
@ -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>,
|
||||||
|
|
||||||
|
@ -0,0 +1,2 @@
|
|||||||
|
-- Revert post.preview_image_url to NOT NULL
|
||||||
|
ALTER TABLE "post" ALTER COLUMN "preview_image_url" SET NOT NULL;
|
@ -0,0 +1,2 @@
|
|||||||
|
-- Make post.preview_image_url nullable
|
||||||
|
ALTER TABLE "post" ALTER COLUMN "preview_image_url" DROP NOT NULL;
|
4
frontend/src/app.d.ts
vendored
4
frontend/src/app.d.ts
vendored
@ -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 {}
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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;
|
|
||||||
}
|
|
41
frontend/src/lib/auth/adapter/presenter/authLoadedStore.ts
Normal file
41
frontend/src/lib/auth/adapter/presenter/authLoadedStore.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
class IdleState<T> extends AsyncState<T> {
|
||||||
status: StatusType.Loading;
|
readonly status = AsyncStateStatus.Idle;
|
||||||
data?: T;
|
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> {
|
class LoadingState<T> extends AsyncState<T> {
|
||||||
status: StatusType.Success;
|
readonly status = AsyncStateStatus.Loading;
|
||||||
data: T;
|
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> {
|
class SuccessState<T> extends AsyncState<T> {
|
||||||
status: StatusType.Error;
|
readonly status = AsyncStateStatus.Success;
|
||||||
data?: T;
|
readonly data: T;
|
||||||
error: Error;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
7
frontend/src/lib/common/adapter/presenter/baseStore.ts
Normal file
7
frontend/src/lib/common/adapter/presenter/baseStore.ts
Normal 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>;
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
import Root from './table.svelte';
|
||||||
|
import Body from './table-body.svelte';
|
||||||
|
import Caption from './table-caption.svelte';
|
||||||
|
import Cell from './table-cell.svelte';
|
||||||
|
import Footer from './table-footer.svelte';
|
||||||
|
import Head from './table-head.svelte';
|
||||||
|
import Header from './table-header.svelte';
|
||||||
|
import Row from './table-row.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Body,
|
||||||
|
Caption,
|
||||||
|
Cell,
|
||||||
|
Footer,
|
||||||
|
Head,
|
||||||
|
Header,
|
||||||
|
Row,
|
||||||
|
//
|
||||||
|
Root as Table,
|
||||||
|
Body as TableBody,
|
||||||
|
Caption as TableCaption,
|
||||||
|
Cell as TableCell,
|
||||||
|
Footer as TableFooter,
|
||||||
|
Head as TableHead,
|
||||||
|
Header as TableHeader,
|
||||||
|
Row as TableRow,
|
||||||
|
};
|
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<tbody
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="table-body"
|
||||||
|
class={cn('[&_tr:last-child]:border-0', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</tbody>
|
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<caption
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="table-caption"
|
||||||
|
class={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</caption>
|
@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||||
|
import type { HTMLTdAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLTdAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<td
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="table-cell"
|
||||||
|
class={cn(
|
||||||
|
'bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</td>
|
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<tfoot
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="table-footer"
|
||||||
|
class={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</tfoot>
|
@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||||
|
import type { HTMLThAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLThAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<th
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="table-head"
|
||||||
|
class={cn(
|
||||||
|
'h-10 bg-clip-padding px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</th>
|
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<thead
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="table-header"
|
||||||
|
class={cn('[&_tr]:border-b', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</thead>
|
@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<tr
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="table-row"
|
||||||
|
class={cn(
|
||||||
|
'border-b transition-colors data-[state=selected]:bg-muted hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</tr>
|
@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLTableAttributes } from 'svelte/elements';
|
||||||
|
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLTableAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div data-slot="table-container" class="relative w-full overflow-x-auto">
|
||||||
|
<table
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="table"
|
||||||
|
class={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</table>
|
||||||
|
</div>
|
8
frontend/src/lib/common/framework/web/httpError.ts
Normal file
8
frontend/src/lib/common/framework/web/httpError.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
url: URL
|
||||||
|
) {
|
||||||
|
super(`HTTP ${status} at ${url.href}`);
|
||||||
|
}
|
||||||
|
}
|
4
frontend/src/lib/common/framework/web/httpStatusCode.ts
Normal file
4
frontend/src/lib/common/framework/web/httpStatusCode.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum HttpStatusCode {
|
||||||
|
UNAUTHORIZED = 401,
|
||||||
|
NOT_FOUND = 404,
|
||||||
|
}
|
147
frontend/src/lib/container.ts
Normal file
147
frontend/src/lib/container.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import MottoAnimatedMark from './MottoAnimatedMark.svelte';
|
import MottoAnimatedMark from '$lib/home/framework/ui/MottoAnimatedMark.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
52
frontend/src/lib/post/adapter/gateway/creatPostRequestDto.ts
Normal file
52
frontend/src/lib/post/adapter/gateway/creatPostRequestDto.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
|
13
frontend/src/lib/post/adapter/gateway/postListQueryDto.ts
Normal file
13
frontend/src/lib/post/adapter/gateway/postListQueryDto.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export class PostListQueryDto {
|
||||||
|
readonly showUnpublished: boolean;
|
||||||
|
|
||||||
|
constructor(props: { showUnpublished: boolean }) {
|
||||||
|
this.showUnpublished = props.showUnpublished;
|
||||||
|
}
|
||||||
|
|
||||||
|
toSearchParams(): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('is_published_only', (!this.showUnpublished).toString());
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
}
|
@ -1,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
|
47
frontend/src/lib/post/adapter/presenter/postCreatedStore.ts
Normal file
47
frontend/src/lib/post/adapter/presenter/postCreatedStore.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
|
51
frontend/src/lib/post/adapter/presenter/postLoadedStore.ts
Normal file
51
frontend/src/lib/post/adapter/presenter/postLoadedStore.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
46
frontend/src/lib/post/adapter/presenter/postsListedStore.ts
Normal file
46
frontend/src/lib/post/adapter/presenter/postsListedStore.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}) {
|
}) {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
107
frontend/src/lib/post/framework/ui/CreatePostDialog.svelte
Normal file
107
frontend/src/lib/post/framework/ui/CreatePostDialog.svelte
Normal 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>
|
@ -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} />
|
||||||
|
71
frontend/src/lib/post/framework/ui/PostManagementPage.svelte
Normal file
71
frontend/src/lib/post/framework/ui/PostManagementPage.svelte
Normal 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>
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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(
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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 />
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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]">
|
||||||
|
@ -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 />
|
||||||
|
13
frontend/src/routes/dashboard/post/+page.server.ts
Normal file
13
frontend/src/routes/dashboard/post/+page.server.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const { container } = locals;
|
||||||
|
const store = container.createPostsListedStore();
|
||||||
|
const { trigger: loadPosts } = store;
|
||||||
|
|
||||||
|
const state = await loadPosts();
|
||||||
|
|
||||||
|
return {
|
||||||
|
dehydratedData: state.data?.map((post) => post.dehydrate()),
|
||||||
|
};
|
||||||
|
};
|
@ -1 +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 />
|
||||||
|
@ -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()),
|
||||||
|
@ -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 />
|
||||||
|
@ -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' });
|
||||||
}
|
}
|
||||||
|
@ -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} />
|
||||||
|
Loading…
x
Reference in New Issue
Block a user