From de2099011bfbeeda8b3c13cfea645762b1a24d66 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Tue, 14 Oct 2025 02:23:06 +0800 Subject: [PATCH] BLOG-127 Image management (upload) (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description - Implement the frontend to upload image. - It will copy the image URL when uploaded. ### Package Changes ```json { "@internationalized/date": "^3.10.0", "@lucide/svelte": "^0.544.0", "bits-ui": "^2.11.5", "mode-watcher": "^1.1.0", "svelte-sonner": "^1.0.5" } ``` ### Screenshots |Scenario|Screenshot| |-|-| |Dialog|![截圖 2025-10-14 凌晨2.16.13.png](/attachments/8b38b87f-7f17-4e31-adc1-cb9660501e87)| |Empty file or invalid format|![截圖 2025-10-14 凌晨2.17.12.png](/attachments/950cebd0-ae6b-4f2b-a821-6d7366e8ae57)| |Success|![截圖 2025-10-14 凌晨2.17.32.png](/attachments/938d8669-598f-4dba-b1ce-24d5f9ae390a)| |Error|![截圖 2025-10-14 凌晨2.18.52.png](/attachments/b35048e6-e012-498b-be77-1f1793b87038)| ### Reference Resolves #127. ### Checklist - [x] A milestone is set - [x] The related issuse has been linked to this branch Reviewed-on: https://git.squidspirit.com/squid/blog/pulls/138 Co-authored-by: SquidSpirit Co-committed-by: SquidSpirit --- frontend/package.json | 6 +- frontend/pnpm-lock.yaml | 188 +++++++++++++++++- frontend/src/app.css | 6 +- frontend/src/lib/auth/domain/entity/user.ts | 6 +- .../components/ui/dialog/dialog-close.svelte | 7 + .../ui/dialog/dialog-content.svelte | 43 ++++ .../ui/dialog/dialog-description.svelte | 17 ++ .../components/ui/dialog/dialog-footer.svelte | 20 ++ .../components/ui/dialog/dialog-header.svelte | 20 ++ .../ui/dialog/dialog-overlay.svelte | 20 ++ .../components/ui/dialog/dialog-title.svelte | 17 ++ .../ui/dialog/dialog-trigger.svelte | 7 + .../framework/components/ui/dialog/index.ts | 37 ++++ .../framework/components/ui/input/index.ts | 7 + .../components/ui/input/input.svelte | 52 +++++ .../framework/components/ui/label/index.ts | 7 + .../components/ui/label/label.svelte | 20 ++ .../framework/components/ui/sonner/index.ts | 1 + .../components/ui/sonner/sonner.svelte | 13 ++ .../framework/ui/DashboardLinkButton.svelte | 2 +- .../src/lib/home/framework/ui/Motto.svelte | 4 +- .../src/lib/home/framework/ui/Terminal.svelte | 2 +- .../lib/home/framework/ui/TitleScreen.svelte | 4 +- .../image/adapter/gateway/imageApiService.ts | 6 + .../adapter/gateway/imageInfoResponseDto.ts | 24 +++ .../adapter/gateway/imageRepositoryImpl.ts | 16 ++ .../lib/image/adapter/presenter/imageBloc.ts | 50 +++++ .../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 | 47 +++++ .../framework/ui/UploadImageDialoag.svelte | 83 ++++++++ frontend/src/lib/post/domain/entity/post.ts | 6 +- .../framework/ui/PostContentHeader.svelte | 4 +- .../post/framework/ui/PostContentPage.svelte | 2 +- .../ui/{Label.svelte => PostLabel.svelte} | 0 .../post/framework/ui/PostOverallPage.svelte | 2 +- .../framework/ui/PostPreviewLabels.svelte | 4 +- frontend/src/routes/+layout.svelte | 2 + frontend/src/routes/dashboard/+layout.svelte | 4 +- .../src/routes/dashboard/image/+page.svelte | 20 +- 43 files changed, 844 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/common/framework/components/ui/dialog/dialog-close.svelte create mode 100644 frontend/src/lib/common/framework/components/ui/dialog/dialog-content.svelte create mode 100644 frontend/src/lib/common/framework/components/ui/dialog/dialog-description.svelte create mode 100644 frontend/src/lib/common/framework/components/ui/dialog/dialog-footer.svelte create mode 100644 frontend/src/lib/common/framework/components/ui/dialog/dialog-header.svelte create mode 100644 frontend/src/lib/common/framework/components/ui/dialog/dialog-overlay.svelte create mode 100644 frontend/src/lib/common/framework/components/ui/dialog/dialog-title.svelte create mode 100644 frontend/src/lib/common/framework/components/ui/dialog/dialog-trigger.svelte create mode 100644 frontend/src/lib/common/framework/components/ui/dialog/index.ts create mode 100644 frontend/src/lib/common/framework/components/ui/input/index.ts create mode 100644 frontend/src/lib/common/framework/components/ui/input/input.svelte create mode 100644 frontend/src/lib/common/framework/components/ui/label/index.ts create mode 100644 frontend/src/lib/common/framework/components/ui/label/label.svelte 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 create mode 100644 frontend/src/lib/image/framework/ui/ImageManagementPage.svelte create mode 100644 frontend/src/lib/image/framework/ui/UploadImageDialoag.svelte rename frontend/src/lib/post/framework/ui/{Label.svelte => PostLabel.svelte} (100%) diff --git a/frontend/package.json b/frontend/package.json index 5fe0ff5..acf56c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,8 @@ "@eslint/compat": "^1.2.5", "@eslint/js": "^9.18.0", "@fortawesome/fontawesome-free": "^7.0.0", - "@lucide/svelte": "^0.545.0", + "@internationalized/date": "^3.10.0", + "@lucide/svelte": "^0.544.0", "@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-node": "^5.2.13", "@sveltejs/kit": "^2.22.0", @@ -29,18 +30,21 @@ "@tailwindcss/vite": "^4.0.0", "@types/markdown-it": "^14.1.2", "@types/sanitize-html": "^2.16.0", + "bits-ui": "^2.11.5", "clsx": "^2.1.1", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "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 ecb9990..98050c9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -21,9 +21,12 @@ importers: '@fortawesome/fontawesome-free': specifier: ^7.0.0 version: 7.0.0 + '@internationalized/date': + specifier: ^3.10.0 + version: 3.10.0 '@lucide/svelte': - specifier: ^0.545.0 - version: 0.545.0(svelte@5.36.13) + specifier: ^0.544.0 + version: 0.544.0(svelte@5.36.13) '@sveltejs/adapter-auto': specifier: ^6.0.0 version: 6.0.1(@sveltejs/kit@2.25.1(@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.13)(vite@7.0.5(@types/node@24.2.0)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.36.13)(vite@7.0.5(@types/node@24.2.0)(jiti@2.4.2)(lightningcss@1.30.1))) @@ -48,6 +51,9 @@ importers: '@types/sanitize-html': specifier: ^2.16.0 version: 2.16.0 + bits-ui: + specifier: ^2.11.5 + version: 2.11.5(@internationalized/date@3.10.0)(svelte@5.36.13) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -66,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 @@ -84,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 @@ -390,6 +402,15 @@ packages: resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@fortawesome/fontawesome-free@7.0.0': resolution: {integrity: sha512-X48nISrSOa89zu2VMljC4XaRf8NmgTwQBVHfS2Nu5G00ZwM31oOVrAtGxZF3b6wDYf9lJsf/Eq4cCSFKIkOWPQ==} engines: {node: '>=6'} @@ -414,6 +435,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@internationalized/date@3.10.0': + resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -431,8 +455,8 @@ packages: '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} - '@lucide/svelte@0.545.0': - resolution: {integrity: sha512-RlAtWefx9MdpXaOMbx3Qv3/NqpeZKOIPxN2D0RBN2+op0opKly8VgYEEWZTT6Ow/zf7UwyTg6/0ExJlsVLK+8g==} + '@lucide/svelte@0.544.0': + resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==} peerDependencies: svelte: ^5 @@ -964,6 +988,9 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || ^7.0.0 + '@swc/helpers@0.5.17': + resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@tailwindcss/node@4.1.11': resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==} @@ -1215,6 +1242,13 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bits-ui@2.11.5: + resolution: {integrity: sha512-d7b6HrrCUeK261c777agFz0G5lx13RMA0DT022e4SRuIjI3bZ8ci53YxIZ2/jpXTmeAeqeShyC+Mgibh9OeW9A==} + engines: {node: '>=20'} + peerDependencies: + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1552,6 +1586,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inline-style-parser@0.2.4: + resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -1782,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==} @@ -2059,6 +2101,31 @@ 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: + svelte: ^5.7.0 + + runed@0.31.1: + resolution: {integrity: sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ==} + peerDependencies: + svelte: ^5.7.0 + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -2109,6 +2176,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-to-object@1.0.11: + resolution: {integrity: sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2134,10 +2204,30 @@ 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'} + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} @@ -2564,6 +2654,17 @@ snapshots: '@eslint/core': 0.15.1 levn: 0.4.1 + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + '@fortawesome/fontawesome-free@7.0.0': {} '@humanfs/core@0.19.1': {} @@ -2579,6 +2680,10 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@internationalized/date@3.10.0': + dependencies: + '@swc/helpers': 0.5.17 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 @@ -2597,7 +2702,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.4 - '@lucide/svelte@0.545.0(svelte@5.36.13)': + '@lucide/svelte@0.544.0(svelte@5.36.13)': dependencies: svelte: 5.36.13 @@ -3216,6 +3321,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@swc/helpers@0.5.17': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.1.11': dependencies: '@ampproject/remapping': 2.3.0 @@ -3483,6 +3592,17 @@ snapshots: binary-extensions@2.3.0: {} + bits-ui@2.11.5(@internationalized/date@3.10.0)(svelte@5.36.13): + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/dom': 1.7.4 + '@internationalized/date': 3.10.0 + esm-env: 1.2.2 + runed: 0.31.1(svelte@5.36.13) + svelte: 5.36.13 + svelte-toolbelt: 0.10.5(svelte@5.36.13) + tabbable: 6.2.0 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3849,6 +3969,8 @@ snapshots: imurmurhash@0.1.4: {} + inline-style-parser@0.2.4: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -4033,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: {} @@ -4233,6 +4361,31 @@ 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 + svelte: 5.36.13 + + runed@0.31.1(svelte@5.36.13): + dependencies: + esm-env: 1.2.2 + svelte: 5.36.13 + sade@1.8.1: dependencies: mri: 1.2.0 @@ -4278,6 +4431,10 @@ snapshots: strip-json-comments@3.1.1: {} + style-to-object@1.0.11: + dependencies: + inline-style-parser: 0.2.4 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4307,6 +4464,25 @@ 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 + runed: 0.29.2(svelte@5.36.13) + 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 @@ -4324,6 +4500,8 @@ snapshots: magic-string: 0.30.17 zimmerframe: 1.1.2 + tabbable@6.2.0: {} + tailwind-merge@3.3.1: {} tailwind-variants@3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.11): diff --git a/frontend/src/app.css b/frontend/src/app.css index de683ad..1572abe 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -153,6 +153,10 @@ button { @apply cursor-pointer; } -.container { +.content-container { @apply mx-auto max-w-screen-xl px-4 md:px-6; } + +.dashboard-container { + @apply mx-auto w-full max-w-screen-lg px-7; +} 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/dialog/dialog-close.svelte b/frontend/src/lib/common/framework/components/ui/dialog/dialog-close.svelte new file mode 100644 index 0000000..e8a96a7 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/dialog/dialog-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/common/framework/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/common/framework/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..0eee489 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,43 @@ + + + + + + {@render children?.()} + {#if showCloseButton} + + + Close + + {/if} + + diff --git a/frontend/src/lib/common/framework/components/ui/dialog/dialog-description.svelte b/frontend/src/lib/common/framework/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..4e02e22 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/common/framework/components/ui/dialog/dialog-footer.svelte b/frontend/src/lib/common/framework/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..81d4175 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/common/framework/components/ui/dialog/dialog-header.svelte b/frontend/src/lib/common/framework/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..21f5851 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/common/framework/components/ui/dialog/dialog-overlay.svelte b/frontend/src/lib/common/framework/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..12048f7 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/common/framework/components/ui/dialog/dialog-title.svelte b/frontend/src/lib/common/framework/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..ed83677 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/common/framework/components/ui/dialog/dialog-trigger.svelte b/frontend/src/lib/common/framework/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..ac04d9f --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/common/framework/components/ui/dialog/index.ts b/frontend/src/lib/common/framework/components/ui/dialog/index.ts new file mode 100644 index 0000000..bb19a72 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/dialog/index.ts @@ -0,0 +1,37 @@ +import { Dialog as DialogPrimitive } from 'bits-ui'; + +import Title from './dialog-title.svelte'; +import Footer from './dialog-footer.svelte'; +import Header from './dialog-header.svelte'; +import Overlay from './dialog-overlay.svelte'; +import Content from './dialog-content.svelte'; +import Description from './dialog-description.svelte'; +import Trigger from './dialog-trigger.svelte'; +import Close from './dialog-close.svelte'; + +const Root = DialogPrimitive.Root; +const Portal = DialogPrimitive.Portal; + +export { + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, +}; diff --git a/frontend/src/lib/common/framework/components/ui/input/index.ts b/frontend/src/lib/common/framework/components/ui/input/index.ts new file mode 100644 index 0000000..88bf92d --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from './input.svelte'; + +export { + Root, + // + Root as Input, +}; diff --git a/frontend/src/lib/common/framework/components/ui/input/input.svelte b/frontend/src/lib/common/framework/components/ui/input/input.svelte new file mode 100644 index 0000000..cc86c50 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/input/input.svelte @@ -0,0 +1,52 @@ + + +{#if type === 'file'} + +{:else} + +{/if} diff --git a/frontend/src/lib/common/framework/components/ui/label/index.ts b/frontend/src/lib/common/framework/components/ui/label/index.ts new file mode 100644 index 0000000..64bfc83 --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from './label.svelte'; + +export { + Root, + // + Root as Label, +}; diff --git a/frontend/src/lib/common/framework/components/ui/label/label.svelte b/frontend/src/lib/common/framework/components/ui/label/label.svelte new file mode 100644 index 0000000..af1db2d --- /dev/null +++ b/frontend/src/lib/common/framework/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + 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/dashboard/framework/ui/DashboardLinkButton.svelte b/frontend/src/lib/dashboard/framework/ui/DashboardLinkButton.svelte index 819b2ce..83c6a24 100644 --- a/frontend/src/lib/dashboard/framework/ui/DashboardLinkButton.svelte +++ b/frontend/src/lib/dashboard/framework/ui/DashboardLinkButton.svelte @@ -1,6 +1,6 @@ -
+
diff --git a/frontend/src/lib/home/framework/ui/Terminal.svelte b/frontend/src/lib/home/framework/ui/Terminal.svelte index 9734c0c..e62571b 100644 --- a/frontend/src/lib/home/framework/ui/Terminal.svelte +++ b/frontend/src/lib/home/framework/ui/Terminal.svelte @@ -56,7 +56,7 @@
-
+

Hello 大家好!

我是 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..ad7a5ba --- /dev/null +++ b/frontend/src/lib/image/adapter/gateway/imageInfoResponseDto.ts @@ -0,0 +1,24 @@ +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..7fceeb1 --- /dev/null +++ b/frontend/src/lib/image/adapter/presenter/imageBloc.ts @@ -0,0 +1,50 @@ +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 }); + + 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; +} 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 new file mode 100644 index 0000000..d29bc1f --- /dev/null +++ b/frontend/src/lib/image/framework/ui/ImageManagementPage.svelte @@ -0,0 +1,47 @@ + + +
+
+

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 new file mode 100644 index 0000000..a54860e --- /dev/null +++ b/frontend/src/lib/image/framework/ui/UploadImageDialoag.svelte @@ -0,0 +1,83 @@ + + + (open = val)}> + Upload + e.preventDefault()} + onEscapeKeydown={(e) => e.preventDefault()} + > + + 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/lib/post/framework/ui/PostContentHeader.svelte b/frontend/src/lib/post/framework/ui/PostContentHeader.svelte index 03cf330..d4b2b95 100644 --- a/frontend/src/lib/post/framework/ui/PostContentHeader.svelte +++ b/frontend/src/lib/post/framework/ui/PostContentHeader.svelte @@ -1,6 +1,6 @@ @@ -8,7 +8,7 @@
{#each postInfo.labels as label (label.id)} -

{postInfo.title}

diff --git a/frontend/src/lib/post/framework/ui/PostContentPage.svelte b/frontend/src/lib/post/framework/ui/PostContentPage.svelte index c8f9365..6612eda 100644 --- a/frontend/src/lib/post/framework/ui/PostContentPage.svelte +++ b/frontend/src/lib/post/framework/ui/PostContentPage.svelte @@ -32,7 +32,7 @@ {/if} {/if} -
+
{#if state.data}
diff --git a/frontend/src/lib/post/framework/ui/Label.svelte b/frontend/src/lib/post/framework/ui/PostLabel.svelte similarity index 100% rename from frontend/src/lib/post/framework/ui/Label.svelte rename to frontend/src/lib/post/framework/ui/PostLabel.svelte diff --git a/frontend/src/lib/post/framework/ui/PostOverallPage.svelte b/frontend/src/lib/post/framework/ui/PostOverallPage.svelte index b41268e..fae886e 100644 --- a/frontend/src/lib/post/framework/ui/PostOverallPage.svelte +++ b/frontend/src/lib/post/framework/ui/PostOverallPage.svelte @@ -17,7 +17,7 @@ content="探索 魚之魷魂 SquidSpirit 的所有文章,這裡是您尋找最新技術洞見與實用教學的園地。" /> -
+

文章

{#each state.data ?? [] as postInfo (postInfo.id)} diff --git a/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte b/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte index 67fa3b0..413b496 100644 --- a/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte +++ b/frontend/src/lib/post/framework/ui/PostPreviewLabels.svelte @@ -1,13 +1,13 @@
{#each labels.slice(0, 2) as label (label.id)} -