feat: implement image upload functionality with Image Management page and related services
Some checks failed
Frontend CI / build (push) Failing after 1m8s
Some checks failed
Frontend CI / build (push) Failing after 1m8s
This commit is contained in:
parent
2fcb502527
commit
b9fed0e340
@ -37,12 +37,14 @@
|
|||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
|
"svelte-sonner": "^1.0.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwind-variants": "^3.1.1",
|
"tailwind-variants": "^3.1.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
70
frontend/pnpm-lock.yaml
generated
70
frontend/pnpm-lock.yaml
generated
@ -72,6 +72,9 @@ importers:
|
|||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^14.1.0
|
specifier: ^14.1.0
|
||||||
version: 14.1.0
|
version: 14.1.0
|
||||||
|
mode-watcher:
|
||||||
|
specifier: ^1.1.0
|
||||||
|
version: 1.1.0(svelte@5.36.13)
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.4.2
|
specifier: ^3.4.2
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
@ -90,6 +93,9 @@ importers:
|
|||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.3.0(picomatch@4.0.3)(svelte@5.36.13)(typescript@5.8.3)
|
version: 4.3.0(picomatch@4.0.3)(svelte@5.36.13)(typescript@5.8.3)
|
||||||
|
svelte-sonner:
|
||||||
|
specifier: ^1.0.5
|
||||||
|
version: 1.0.5(svelte@5.36.13)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
@ -1813,6 +1819,11 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
mode-watcher@1.1.0:
|
||||||
|
resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.27.0
|
||||||
|
|
||||||
module-details-from-path@1.0.4:
|
module-details-from-path@1.0.4:
|
||||||
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
|
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
|
||||||
|
|
||||||
@ -2090,6 +2101,21 @@ packages:
|
|||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
|
runed@0.23.4:
|
||||||
|
resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.7.0
|
||||||
|
|
||||||
|
runed@0.25.0:
|
||||||
|
resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.7.0
|
||||||
|
|
||||||
|
runed@0.28.0:
|
||||||
|
resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.7.0
|
||||||
|
|
||||||
runed@0.29.2:
|
runed@0.29.2:
|
||||||
resolution: {integrity: sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==}
|
resolution: {integrity: sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2178,12 +2204,23 @@ packages:
|
|||||||
svelte:
|
svelte:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
svelte-sonner@1.0.5:
|
||||||
|
resolution: {integrity: sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.0.0
|
||||||
|
|
||||||
svelte-toolbelt@0.10.5:
|
svelte-toolbelt@0.10.5:
|
||||||
resolution: {integrity: sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w==}
|
resolution: {integrity: sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w==}
|
||||||
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.30.2
|
svelte: ^5.30.2
|
||||||
|
|
||||||
|
svelte-toolbelt@0.7.1:
|
||||||
|
resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==}
|
||||||
|
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.0.0
|
||||||
|
|
||||||
svelte@5.36.13:
|
svelte@5.36.13:
|
||||||
resolution: {integrity: sha512-LnSywHHQM/nJekC65d84T1Yo85IeCYN4AryWYPhTokSvcEAFdYFCfbMhX1mc0zHizT736QQj0nalUk+SXaWrEQ==}
|
resolution: {integrity: sha512-LnSywHHQM/nJekC65d84T1Yo85IeCYN4AryWYPhTokSvcEAFdYFCfbMhX1mc0zHizT736QQj0nalUk+SXaWrEQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -4118,6 +4155,12 @@ snapshots:
|
|||||||
|
|
||||||
mkdirp@3.0.1: {}
|
mkdirp@3.0.1: {}
|
||||||
|
|
||||||
|
mode-watcher@1.1.0(svelte@5.36.13):
|
||||||
|
dependencies:
|
||||||
|
runed: 0.25.0(svelte@5.36.13)
|
||||||
|
svelte: 5.36.13
|
||||||
|
svelte-toolbelt: 0.7.1(svelte@5.36.13)
|
||||||
|
|
||||||
module-details-from-path@1.0.4: {}
|
module-details-from-path@1.0.4: {}
|
||||||
|
|
||||||
mri@1.2.0: {}
|
mri@1.2.0: {}
|
||||||
@ -4318,6 +4361,21 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
|
|
||||||
|
runed@0.23.4(svelte@5.36.13):
|
||||||
|
dependencies:
|
||||||
|
esm-env: 1.2.2
|
||||||
|
svelte: 5.36.13
|
||||||
|
|
||||||
|
runed@0.25.0(svelte@5.36.13):
|
||||||
|
dependencies:
|
||||||
|
esm-env: 1.2.2
|
||||||
|
svelte: 5.36.13
|
||||||
|
|
||||||
|
runed@0.28.0(svelte@5.36.13):
|
||||||
|
dependencies:
|
||||||
|
esm-env: 1.2.2
|
||||||
|
svelte: 5.36.13
|
||||||
|
|
||||||
runed@0.29.2(svelte@5.36.13):
|
runed@0.29.2(svelte@5.36.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
esm-env: 1.2.2
|
esm-env: 1.2.2
|
||||||
@ -4406,6 +4464,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
svelte: 5.36.13
|
svelte: 5.36.13
|
||||||
|
|
||||||
|
svelte-sonner@1.0.5(svelte@5.36.13):
|
||||||
|
dependencies:
|
||||||
|
runed: 0.28.0(svelte@5.36.13)
|
||||||
|
svelte: 5.36.13
|
||||||
|
|
||||||
svelte-toolbelt@0.10.5(svelte@5.36.13):
|
svelte-toolbelt@0.10.5(svelte@5.36.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
@ -4413,6 +4476,13 @@ snapshots:
|
|||||||
style-to-object: 1.0.11
|
style-to-object: 1.0.11
|
||||||
svelte: 5.36.13
|
svelte: 5.36.13
|
||||||
|
|
||||||
|
svelte-toolbelt@0.7.1(svelte@5.36.13):
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
runed: 0.23.4(svelte@5.36.13)
|
||||||
|
style-to-object: 1.0.11
|
||||||
|
svelte: 5.36.13
|
||||||
|
|
||||||
svelte@5.36.13:
|
svelte@5.36.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
export class User {
|
export class User {
|
||||||
id: number;
|
readonly id: number;
|
||||||
name: string;
|
readonly name: string;
|
||||||
email: string;
|
readonly email: string;
|
||||||
|
|
||||||
constructor(props: { id: number; name: string; email: string }) {
|
constructor(props: { id: number; name: string; email: string }) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
export { default as Toaster } from './sonner.svelte';
|
@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
|
||||||
|
let { ...restProps }: SonnerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sonner
|
||||||
|
theme={mode.current}
|
||||||
|
class="toaster group"
|
||||||
|
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
@ -0,0 +1,6 @@
|
|||||||
|
import type { ImageInfoResponseDto } from '$lib/image/adapter/gateway/imageInfoResponseDto';
|
||||||
|
|
||||||
|
export interface ImageApiService {
|
||||||
|
uploadImage(file: File): Promise<ImageInfoResponseDto>;
|
||||||
|
getUrlFromId(id: number): URL;
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { ImageInfo } from '$lib/image/domain/entity/imageInfo';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
export const ImageInfoResponseSchema = z.object({
|
||||||
|
id: z.int32(),
|
||||||
|
mime_type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ImageInfoResponseDto {
|
||||||
|
readonly id: number;
|
||||||
|
readonly mimeType: string;
|
||||||
|
|
||||||
|
private constructor(props: { id: number; mimeType: string }) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.mimeType = props.mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json: unknown): ImageInfoResponseDto {
|
||||||
|
const parsedJson = ImageInfoResponseSchema.parse(json);
|
||||||
|
return new ImageInfoResponseDto({
|
||||||
|
id: parsedJson.id,
|
||||||
|
mimeType: parsedJson.mime_type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
|
||||||
|
import type { ImageRepository } from '$lib/image/application/gateway/imageRepository';
|
||||||
|
import { ImageInfo } from '$lib/image/domain/entity/imageInfo';
|
||||||
|
|
||||||
|
export class ImageRepositoryImpl implements ImageRepository {
|
||||||
|
constructor(private readonly imageApiService: ImageApiService) {}
|
||||||
|
|
||||||
|
async uploadImage(file: File): Promise<ImageInfo> {
|
||||||
|
const dto = await this.imageApiService.uploadImage(file);
|
||||||
|
return new ImageInfo({
|
||||||
|
id: dto.id,
|
||||||
|
mimeType: dto.mimeType,
|
||||||
|
url: this.imageApiService.getUrlFromId(dto.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
51
frontend/src/lib/image/adapter/presenter/imageBloc.ts
Normal file
51
frontend/src/lib/image/adapter/presenter/imageBloc.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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 });
|
||||||
|
|
||||||
|
var 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.set(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ImageEventType {
|
||||||
|
ImageUploadedEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageUploadedEvent {
|
||||||
|
event: ImageEventType.ImageUploadedEvent;
|
||||||
|
file: File;
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import type { ImageInfo } from '$lib/image/domain/entity/imageInfo';
|
||||||
|
|
||||||
|
export class ImageInfoViewModel {
|
||||||
|
readonly id: number;
|
||||||
|
readonly mimeType: string;
|
||||||
|
readonly url: URL;
|
||||||
|
|
||||||
|
private constructor(props: { id: number; mimeType: string; url: URL }) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.mimeType = props.mimeType;
|
||||||
|
this.url = props.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEntity(imageInfo: ImageInfo): ImageInfoViewModel {
|
||||||
|
return new ImageInfoViewModel(imageInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
static rehydrate(props: DehydratedImageInfoProps): ImageInfoViewModel {
|
||||||
|
return new ImageInfoViewModel({
|
||||||
|
id: props.id,
|
||||||
|
mimeType: props.mimeType,
|
||||||
|
url: new URL(props.url),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dehydrate(): DehydratedImageInfoProps {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
mimeType: this.mimeType,
|
||||||
|
url: this.url.href,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DehydratedImageInfoProps {
|
||||||
|
id: number;
|
||||||
|
mimeType: string;
|
||||||
|
url: string;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import type { ImageInfo } from '$lib/image/domain/entity/imageInfo';
|
||||||
|
|
||||||
|
export interface ImageRepository {
|
||||||
|
uploadImage(file: File): Promise<ImageInfo>;
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
import type { ImageRepository } from '$lib/image/application/gateway/imageRepository';
|
||||||
|
import type { ImageInfo } from '$lib/image/domain/entity/imageInfo';
|
||||||
|
|
||||||
|
export class UploadImageUseCase {
|
||||||
|
constructor(private readonly imageRepository: ImageRepository) {}
|
||||||
|
|
||||||
|
execute(file: File): Promise<ImageInfo> {
|
||||||
|
return this.imageRepository.uploadImage(file);
|
||||||
|
}
|
||||||
|
}
|
11
frontend/src/lib/image/domain/entity/imageInfo.ts
Normal file
11
frontend/src/lib/image/domain/entity/imageInfo.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export class ImageInfo {
|
||||||
|
readonly id: number;
|
||||||
|
readonly mimeType: string;
|
||||||
|
readonly url: URL;
|
||||||
|
|
||||||
|
constructor(props: { id: number; mimeType: string; url: URL }) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.mimeType = props.mimeType;
|
||||||
|
this.url = props.url;
|
||||||
|
}
|
||||||
|
}
|
29
frontend/src/lib/image/framework/api/imageApiServiceImpl.ts
Normal file
29
frontend/src/lib/image/framework/api/imageApiServiceImpl.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Environment } from '$lib/environment';
|
||||||
|
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
|
||||||
|
import { ImageInfoResponseDto } from '$lib/image/adapter/gateway/imageInfoResponseDto';
|
||||||
|
|
||||||
|
export class ImageApiServiceImpl implements ImageApiService {
|
||||||
|
constructor(private readonly fetchFn: typeof fetch) {}
|
||||||
|
|
||||||
|
async uploadImage(file: File): Promise<ImageInfoResponseDto> {
|
||||||
|
const url = new URL('image/upload', Environment.API_BASE_URL);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await this.fetchFn(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return ImageInfoResponseDto.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUrlFromId(id: number): URL {
|
||||||
|
return new URL(`image/${id}`, Environment.API_BASE_URL);
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,45 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import { getContext } from 'svelte';
|
||||||
import UploadImageDialoag from './UploadImageDialoag.svelte';
|
import UploadImageDialoag from './UploadImageDialoag.svelte';
|
||||||
|
import { ImageBloc, ImageEventType } from '$lib/image/adapter/presenter/imageBloc';
|
||||||
|
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
const imageBloc = getContext<ImageBloc>(ImageBloc.name);
|
||||||
|
const state = $derived($imageBloc);
|
||||||
|
|
||||||
|
const isLoading = $derived(state.status === StatusType.Loading);
|
||||||
|
|
||||||
|
async function onUploadImageDialogSubmit(file: File) {
|
||||||
|
const state = await imageBloc.dispatch({ event: ImageEventType.ImageUploadedEvent, file });
|
||||||
|
|
||||||
|
if (state.status === StatusType.Success) {
|
||||||
|
const imageInfo = state.data;
|
||||||
|
console.log('Image URL', imageInfo.url.href);
|
||||||
|
|
||||||
|
let copiedToClipboard = false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(imageInfo.url.href);
|
||||||
|
copiedToClipboard = true;
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
toast.success(`Image uploaded successfully with ID: ${imageInfo.id}`, {
|
||||||
|
description: copiedToClipboard
|
||||||
|
? 'The URL is copied to clipboard'
|
||||||
|
: 'The URL is printed in console',
|
||||||
|
});
|
||||||
|
} else if (state.status === StatusType.Error) {
|
||||||
|
toast.error('Failed to upload image', {
|
||||||
|
description: state.error?.message ?? 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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 />
|
<UploadImageDialoag disabled={isLoading} onSubmit={onUploadImageDialogSubmit} />
|
||||||
</div>
|
</div>
|
||||||
<p>Gallery is currently unavailable.</p>
|
<p>Gallery is currently unavailable.</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import { buttonVariants } from '$lib/common/framework/components/ui/button';
|
import { buttonVariants } from '$lib/common/framework/components/ui/button';
|
||||||
import Button from '$lib/common/framework/components/ui/button/button.svelte';
|
import Button from '$lib/common/framework/components/ui/button/button.svelte';
|
||||||
import { Dialog } from '$lib/common/framework/components/ui/dialog';
|
import { Dialog } from '$lib/common/framework/components/ui/dialog';
|
||||||
import DialogClose from '$lib/common/framework/components/ui/dialog/dialog-close.svelte';
|
|
||||||
import DialogContent from '$lib/common/framework/components/ui/dialog/dialog-content.svelte';
|
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 DialogFooter from '$lib/common/framework/components/ui/dialog/dialog-footer.svelte';
|
||||||
import DialogHeader from '$lib/common/framework/components/ui/dialog/dialog-header.svelte';
|
import DialogHeader from '$lib/common/framework/components/ui/dialog/dialog-header.svelte';
|
||||||
@ -11,50 +10,70 @@
|
|||||||
import Input from '$lib/common/framework/components/ui/input/input.svelte';
|
import Input from '$lib/common/framework/components/ui/input/input.svelte';
|
||||||
import Label from '$lib/common/framework/components/ui/label/label.svelte';
|
import Label from '$lib/common/framework/components/ui/label/label.svelte';
|
||||||
|
|
||||||
|
const {
|
||||||
|
disabled,
|
||||||
|
onSubmit: uploadImage,
|
||||||
|
}: {
|
||||||
|
disabled: boolean;
|
||||||
|
onSubmit: (file: File) => Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const imageMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
let files: FileList | undefined = $state(undefined);
|
let files: FileList | undefined = $state(undefined);
|
||||||
let fileInputErrorMessage: string | null = $state(null);
|
let fileInputErrorMessage: string | null = $state(null);
|
||||||
const isFileInputError = $derived(fileInputErrorMessage !== null);
|
const isFileInputError = $derived(fileInputErrorMessage !== null);
|
||||||
|
|
||||||
function onSubmit(event: SubmitEvent) {
|
async function onSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
fileInputErrorMessage = null;
|
fileInputErrorMessage = null;
|
||||||
|
|
||||||
const file = files?.[0];
|
const file = files?.[0];
|
||||||
if (!file || !file.type.startsWith('image/')) {
|
if (!file || !imageMimeTypes.includes(file.type)) {
|
||||||
fileInputErrorMessage = 'Please select an valid image file.';
|
fileInputErrorMessage = 'Please select an valid image file.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Submitting file:', file);
|
await uploadImage(file);
|
||||||
|
close();
|
||||||
|
files = undefined;
|
||||||
|
fileInputErrorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog>
|
<Dialog {open} onOpenChange={(val) => (open = val)}>
|
||||||
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Upload</DialogTrigger>
|
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Upload</DialogTrigger>
|
||||||
<DialogContent showCloseButton={false}>
|
<DialogContent showCloseButton={false}>
|
||||||
<DialogHeader class="mb-4">
|
<DialogHeader class="mb-4">
|
||||||
<DialogTitle>Upload Image</DialogTitle>
|
<DialogTitle>Upload Image</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form id="upload-form" onsubmit={onSubmit}>
|
||||||
<Label for="file-input" class="pb-2">Image File</Label>
|
<Label for="file-input" class="pb-2">
|
||||||
|
{`Image File (${imageMimeTypes.join(', ')})`}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="file-input"
|
id="file-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept={imageMimeTypes.join(',')}
|
||||||
aria-invalid={isFileInputError}
|
aria-invalid={isFileInputError}
|
||||||
|
class="cursor-pointer"
|
||||||
bind:files
|
bind:files
|
||||||
|
{disabled}
|
||||||
/>
|
/>
|
||||||
{#if isFileInputError}
|
{#if isFileInputError}
|
||||||
<p class="text-sm text-red-500">{fileInputErrorMessage}</p>
|
<p class="text-sm text-red-500">{fileInputErrorMessage}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<DialogFooter class="mt-6">
|
|
||||||
<DialogClose>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button type="submit">Submit</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<DialogFooter class="mt-6">
|
||||||
|
<Button variant="outline" onclick={close} {disabled}>Cancel</Button>
|
||||||
|
<Button type="submit" form="upload-form" {disabled}>Submit</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
|
||||||
|
|
||||||
export class Post {
|
export class Post {
|
||||||
id: number;
|
readonly id: number;
|
||||||
info: PostInfo;
|
readonly info: PostInfo;
|
||||||
content: string;
|
readonly content: string;
|
||||||
|
|
||||||
constructor(props: { id: number; info: PostInfo; content: string }) {
|
constructor(props: { id: number; info: PostInfo; content: string }) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
import GoogleAnalytics from '$lib/common/framework/ui/GoogleAnalytics.svelte';
|
import GoogleAnalytics from '$lib/common/framework/ui/GoogleAnalytics.svelte';
|
||||||
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';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<GoogleAnalytics />
|
<GoogleAnalytics />
|
||||||
@ -11,6 +12,7 @@
|
|||||||
<meta name="app-version" content={App.__VERSION__} />
|
<meta name="app-version" content={App.__VERSION__} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
|
<Toaster theme="light" />
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main>
|
<main>
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -24,8 +24,8 @@
|
|||||||
onMount(() => authBloc.dispatch({ event: AuthEventType.CurrentUserLoadedEvent }));
|
onMount(() => authBloc.dispatch({ event: AuthEventType.CurrentUserLoadedEvent }));
|
||||||
|
|
||||||
const authState = $derived($authBloc);
|
const authState = $derived($authBloc);
|
||||||
const isLoading = $derived.by(
|
const isLoading = $derived(
|
||||||
() => authState.status === StatusType.Loading || authState.status === StatusType.Idle
|
authState.status === StatusType.Loading || authState.status === StatusType.Idle
|
||||||
);
|
);
|
||||||
const hasError = $derived.by(() => {
|
const hasError = $derived.by(() => {
|
||||||
if (authState.status === StatusType.Error) {
|
if (authState.status === StatusType.Error) {
|
||||||
|
@ -1,5 +1,19 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
|
||||||
|
import { ImageRepositoryImpl } from '$lib/image/adapter/gateway/imageRepositoryImpl';
|
||||||
|
import { ImageBloc } from '$lib/image/adapter/presenter/imageBloc';
|
||||||
|
import type { ImageRepository } from '$lib/image/application/gateway/imageRepository';
|
||||||
|
import { UploadImageUseCase } from '$lib/image/application/useCase/uploadImageUseCase';
|
||||||
|
import { ImageApiServiceImpl } from '$lib/image/framework/api/imageApiServiceImpl';
|
||||||
import ImageManagementPage from '$lib/image/framework/ui/ImageManagementPage.svelte';
|
import ImageManagementPage from '$lib/image/framework/ui/ImageManagementPage.svelte';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
|
||||||
|
const imageApiService: ImageApiService = new ImageApiServiceImpl(fetch);
|
||||||
|
const imageRepository: ImageRepository = new ImageRepositoryImpl(imageApiService);
|
||||||
|
const uploadImageUseCase = new UploadImageUseCase(imageRepository);
|
||||||
|
const imageBloc = new ImageBloc(uploadImageUseCase);
|
||||||
|
|
||||||
|
setContext(ImageBloc.name, imageBloc);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ImageManagementPage />
|
<ImageManagementPage />
|
||||||
|
Loading…
x
Reference in New Issue
Block a user