From b9fed0e340fd962512a0741d438b9b48bb96c8d9 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Tue, 14 Oct 2025 02:03:34 +0800 Subject: [PATCH] feat: implement image upload functionality with Image Management page and related services --- frontend/package.json | 2 + frontend/pnpm-lock.yaml | 70 +++++++++++++++++++ frontend/src/lib/auth/domain/entity/user.ts | 6 +- .../framework/components/ui/sonner/index.ts | 1 + .../components/ui/sonner/sonner.svelte | 13 ++++ .../image/adapter/gateway/imageApiService.ts | 6 ++ .../adapter/gateway/imageInfoResponseDto.ts | 25 +++++++ .../adapter/gateway/imageRepositoryImpl.ts | 16 +++++ .../lib/image/adapter/presenter/imageBloc.ts | 51 ++++++++++++++ .../adapter/presenter/imageInfoViewModel.ts | 39 +++++++++++ .../application/gateway/imageRepository.ts | 5 ++ .../application/useCase/uploadImageUseCase.ts | 10 +++ .../src/lib/image/domain/entity/imageInfo.ts | 11 +++ .../framework/api/imageApiServiceImpl.ts | 29 ++++++++ .../framework/ui/ImageManagementPage.svelte | 38 +++++++++- .../framework/ui/UploadImageDialoag.svelte | 49 +++++++++---- frontend/src/lib/post/domain/entity/post.ts | 6 +- frontend/src/routes/+layout.svelte | 2 + frontend/src/routes/dashboard/+layout.svelte | 4 +- .../src/routes/dashboard/image/+page.svelte | 16 ++++- 20 files changed, 373 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/common/framework/components/ui/sonner/index.ts create mode 100644 frontend/src/lib/common/framework/components/ui/sonner/sonner.svelte create mode 100644 frontend/src/lib/image/adapter/gateway/imageApiService.ts create mode 100644 frontend/src/lib/image/adapter/gateway/imageInfoResponseDto.ts create mode 100644 frontend/src/lib/image/adapter/gateway/imageRepositoryImpl.ts create mode 100644 frontend/src/lib/image/adapter/presenter/imageBloc.ts create mode 100644 frontend/src/lib/image/adapter/presenter/imageInfoViewModel.ts create mode 100644 frontend/src/lib/image/application/gateway/imageRepository.ts create mode 100644 frontend/src/lib/image/application/useCase/uploadImageUseCase.ts create mode 100644 frontend/src/lib/image/domain/entity/imageInfo.ts create mode 100644 frontend/src/lib/image/framework/api/imageApiServiceImpl.ts diff --git a/frontend/package.json b/frontend/package.json index 8e6a8f4..acf56c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,12 +37,14 @@ "eslint-plugin-svelte": "^3.0.0", "globals": "^16.0.0", "markdown-it": "^14.1.0", + "mode-watcher": "^1.1.0", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", "sanitize-html": "^2.17.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "svelte-sonner": "^1.0.5", "tailwind-merge": "^3.3.1", "tailwind-variants": "^3.1.1", "tailwindcss": "^4.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f3a1370..98050c9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: markdown-it: specifier: ^14.1.0 version: 14.1.0 + mode-watcher: + specifier: ^1.1.0 + version: 1.1.0(svelte@5.36.13) prettier: specifier: ^3.4.2 version: 3.6.2 @@ -90,6 +93,9 @@ importers: svelte-check: specifier: ^4.0.0 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: specifier: ^3.3.1 version: 3.3.1 @@ -1813,6 +1819,11 @@ packages: engines: {node: '>=10'} 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: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -2090,6 +2101,21 @@ packages: run-parallel@1.2.0: 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: resolution: {integrity: sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==} peerDependencies: @@ -2178,12 +2204,23 @@ packages: svelte: optional: true + svelte-sonner@1.0.5: + resolution: {integrity: sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg==} + peerDependencies: + svelte: ^5.0.0 + svelte-toolbelt@0.10.5: resolution: {integrity: sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w==} engines: {node: '>=18', pnpm: '>=8.7.0'} peerDependencies: 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: resolution: {integrity: sha512-LnSywHHQM/nJekC65d84T1Yo85IeCYN4AryWYPhTokSvcEAFdYFCfbMhX1mc0zHizT736QQj0nalUk+SXaWrEQ==} engines: {node: '>=18'} @@ -4118,6 +4155,12 @@ snapshots: 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: {} mri@1.2.0: {} @@ -4318,6 +4361,21 @@ snapshots: dependencies: 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): dependencies: esm-env: 1.2.2 @@ -4406,6 +4464,11 @@ snapshots: optionalDependencies: 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): dependencies: clsx: 2.1.1 @@ -4413,6 +4476,13 @@ snapshots: style-to-object: 1.0.11 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: dependencies: '@ampproject/remapping': 2.3.0 diff --git a/frontend/src/lib/auth/domain/entity/user.ts b/frontend/src/lib/auth/domain/entity/user.ts index 43a2157..42d728c 100644 --- a/frontend/src/lib/auth/domain/entity/user.ts +++ b/frontend/src/lib/auth/domain/entity/user.ts @@ -1,7 +1,7 @@ export class User { - id: number; - name: string; - email: string; + readonly id: number; + readonly name: string; + readonly email: string; constructor(props: { id: number; name: string; email: string }) { this.id = props.id; diff --git a/frontend/src/lib/common/framework/components/ui/sonner/index.ts b/frontend/src/lib/common/framework/components/ui/sonner/index.ts new file mode 100644 index 0000000..fcaf06b --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from './sonner.svelte'; diff --git a/frontend/src/lib/common/framework/components/ui/sonner/sonner.svelte b/frontend/src/lib/common/framework/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..cb1f7c1 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/sonner/sonner.svelte @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/lib/image/adapter/gateway/imageApiService.ts b/frontend/src/lib/image/adapter/gateway/imageApiService.ts new file mode 100644 index 0000000..ad58feb --- /dev/null +++ b/frontend/src/lib/image/adapter/gateway/imageApiService.ts @@ -0,0 +1,6 @@ +import type { ImageInfoResponseDto } from '$lib/image/adapter/gateway/imageInfoResponseDto'; + +export interface ImageApiService { + uploadImage(file: File): Promise; + getUrlFromId(id: number): URL; +} diff --git a/frontend/src/lib/image/adapter/gateway/imageInfoResponseDto.ts b/frontend/src/lib/image/adapter/gateway/imageInfoResponseDto.ts new file mode 100644 index 0000000..abc2ccd --- /dev/null +++ b/frontend/src/lib/image/adapter/gateway/imageInfoResponseDto.ts @@ -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, + }); + } +} diff --git a/frontend/src/lib/image/adapter/gateway/imageRepositoryImpl.ts b/frontend/src/lib/image/adapter/gateway/imageRepositoryImpl.ts new file mode 100644 index 0000000..70f115b --- /dev/null +++ b/frontend/src/lib/image/adapter/gateway/imageRepositoryImpl.ts @@ -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 { + const dto = await this.imageApiService.uploadImage(file); + return new ImageInfo({ + id: dto.id, + mimeType: dto.mimeType, + url: this.imageApiService.getUrlFromId(dto.id), + }); + } +} diff --git a/frontend/src/lib/image/adapter/presenter/imageBloc.ts b/frontend/src/lib/image/adapter/presenter/imageBloc.ts new file mode 100644 index 0000000..0a128a6 --- /dev/null +++ b/frontend/src/lib/image/adapter/presenter/imageBloc.ts @@ -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; +export type ImageEvent = ImageUploadedEvent; + +export class ImageBloc { + private readonly state = writable({ + status: StatusType.Idle, + }); + + constructor(private readonly uploadImageUseCase: UploadImageUseCase) {} + + get subscribe() { + return this.state.subscribe; + } + + async dispatch(event: ImageEvent): Promise { + switch (event.event) { + case ImageEventType.ImageUploadedEvent: + return this.uploadImage(event.file); + } + } + + private async uploadImage(file: File): Promise { + 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; +} diff --git a/frontend/src/lib/image/adapter/presenter/imageInfoViewModel.ts b/frontend/src/lib/image/adapter/presenter/imageInfoViewModel.ts new file mode 100644 index 0000000..a981f42 --- /dev/null +++ b/frontend/src/lib/image/adapter/presenter/imageInfoViewModel.ts @@ -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; +} diff --git a/frontend/src/lib/image/application/gateway/imageRepository.ts b/frontend/src/lib/image/application/gateway/imageRepository.ts new file mode 100644 index 0000000..5f034b6 --- /dev/null +++ b/frontend/src/lib/image/application/gateway/imageRepository.ts @@ -0,0 +1,5 @@ +import type { ImageInfo } from '$lib/image/domain/entity/imageInfo'; + +export interface ImageRepository { + uploadImage(file: File): Promise; +} diff --git a/frontend/src/lib/image/application/useCase/uploadImageUseCase.ts b/frontend/src/lib/image/application/useCase/uploadImageUseCase.ts new file mode 100644 index 0000000..46f42ab --- /dev/null +++ b/frontend/src/lib/image/application/useCase/uploadImageUseCase.ts @@ -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 { + return this.imageRepository.uploadImage(file); + } +} diff --git a/frontend/src/lib/image/domain/entity/imageInfo.ts b/frontend/src/lib/image/domain/entity/imageInfo.ts new file mode 100644 index 0000000..e3520e6 --- /dev/null +++ b/frontend/src/lib/image/domain/entity/imageInfo.ts @@ -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; + } +} diff --git a/frontend/src/lib/image/framework/api/imageApiServiceImpl.ts b/frontend/src/lib/image/framework/api/imageApiServiceImpl.ts new file mode 100644 index 0000000..276aaf4 --- /dev/null +++ b/frontend/src/lib/image/framework/api/imageApiServiceImpl.ts @@ -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 { + 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); + } +} diff --git a/frontend/src/lib/image/framework/ui/ImageManagementPage.svelte b/frontend/src/lib/image/framework/ui/ImageManagementPage.svelte index 1d3c36b..bbdb670 100644 --- a/frontend/src/lib/image/framework/ui/ImageManagementPage.svelte +++ b/frontend/src/lib/image/framework/ui/ImageManagementPage.svelte @@ -1,11 +1,45 @@ -

Image

- +

Gallery is currently unavailable.

diff --git a/frontend/src/lib/image/framework/ui/UploadImageDialoag.svelte b/frontend/src/lib/image/framework/ui/UploadImageDialoag.svelte index cd5a84c..4d33ac2 100644 --- a/frontend/src/lib/image/framework/ui/UploadImageDialoag.svelte +++ b/frontend/src/lib/image/framework/ui/UploadImageDialoag.svelte @@ -2,7 +2,6 @@ import { buttonVariants } from '$lib/common/framework/components/ui/button'; import Button from '$lib/common/framework/components/ui/button/button.svelte'; 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 DialogFooter from '$lib/common/framework/components/ui/dialog/dialog-footer.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 Label from '$lib/common/framework/components/ui/label/label.svelte'; + const { + disabled, + onSubmit: uploadImage, + }: { + disabled: boolean; + onSubmit: (file: File) => Promise; + } = $props(); + + const imageMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + + let open = $state(false); let files: FileList | undefined = $state(undefined); let fileInputErrorMessage: string | null = $state(null); const isFileInputError = $derived(fileInputErrorMessage !== null); - function onSubmit(event: SubmitEvent) { + async function onSubmit(event: SubmitEvent) { event.preventDefault(); fileInputErrorMessage = null; const file = files?.[0]; - if (!file || !file.type.startsWith('image/')) { + if (!file || !imageMimeTypes.includes(file.type)) { fileInputErrorMessage = 'Please select an valid image file.'; return; } - console.log('Submitting file:', file); + await uploadImage(file); + close(); + files = undefined; + fileInputErrorMessage = null; + } + + function close() { + open = false; } - + (open = val)}> Upload Upload Image -
- + + {#if isFileInputError}

{fileInputErrorMessage}

{/if} - - - - - - -
+ + + + +
diff --git a/frontend/src/lib/post/domain/entity/post.ts b/frontend/src/lib/post/domain/entity/post.ts index 458a4de..e34b548 100644 --- a/frontend/src/lib/post/domain/entity/post.ts +++ b/frontend/src/lib/post/domain/entity/post.ts @@ -1,9 +1,9 @@ import type { PostInfo } from '$lib/post/domain/entity/postInfo'; export class Post { - id: number; - info: PostInfo; - content: string; + readonly id: number; + readonly info: PostInfo; + readonly content: string; constructor(props: { id: number; info: PostInfo; content: string }) { this.id = props.id; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 2383ed1..7a87f94 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import GoogleAnalytics from '$lib/common/framework/ui/GoogleAnalytics.svelte'; import '../app.css'; import '@fortawesome/fontawesome-free/css/all.min.css'; + import { Toaster } from '$lib/common/framework/components/ui/sonner'; @@ -11,6 +12,7 @@
+
diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 548315a..47f29e8 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -24,8 +24,8 @@ onMount(() => authBloc.dispatch({ event: AuthEventType.CurrentUserLoadedEvent })); const authState = $derived($authBloc); - const isLoading = $derived.by( - () => authState.status === StatusType.Loading || authState.status === StatusType.Idle + const isLoading = $derived( + authState.status === StatusType.Loading || authState.status === StatusType.Idle ); const hasError = $derived.by(() => { if (authState.status === StatusType.Error) { diff --git a/frontend/src/routes/dashboard/image/+page.svelte b/frontend/src/routes/dashboard/image/+page.svelte index 9ca84b1..5fc7351 100644 --- a/frontend/src/routes/dashboard/image/+page.svelte +++ b/frontend/src/routes/dashboard/image/+page.svelte @@ -1,5 +1,19 @@ -