BLOG-127 Image management (upload) (#138)
All checks were successful
Frontend CI / build (push) Successful in 1m37s

### 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: #138
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
This commit is contained in:
SquidSpirit 2025-10-14 02:23:06 +08:00 committed by squid
parent e8c5e678d5
commit de2099011b
43 changed files with 844 additions and 26 deletions

View File

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

188
frontend/pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@ -0,0 +1,43 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/common/framework/components/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="absolute end-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/common/framework/components/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/common/framework/components/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...restProps}
/>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/common/framework/components/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn('text-lg leading-none font-semibold', className)}
{...restProps}
/>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@ -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,
};

View File

@ -0,0 +1,7 @@
import Root from './input.svelte';
export {
Root,
//
Root as Input,
};

View File

@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
type Props = WithElementRef<
Omit<HTMLInputAttributes, 'type'> &
({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
'data-slot': dataSlot = 'input',
...restProps
}: Props = $props();
</script>
{#if type === 'file'}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@ -0,0 +1,7 @@
import Root from './label.svelte';
export {
Root,
//
Root as Label,
};

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/common/framework/components/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...restProps}
/>

View File

@ -0,0 +1 @@
export { default as Toaster } from './sonner.svelte';

View File

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

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '$lib/common/framework/components/ui/button/index';
import { Button } from '$lib/common/framework/components/ui/button';
import type { DashboardLink } from '$lib/dashboard/framework/ui/dashboardLink';
const { link }: { link: DashboardLink } = $props();

View File

@ -2,7 +2,9 @@
import MottoAnimatedMark from './MottoAnimatedMark.svelte';
</script>
<div class="container flex h-screen flex-col items-center justify-center gap-y-2.5 md:gap-y-8">
<div
class="content-container flex h-screen flex-col items-center justify-center gap-y-2.5 md:gap-y-8"
>
<div
class="flex w-[19rem] flex-col gap-y-3 text-3xl font-bold text-gray-800 md:w-[38rem] md:gap-y-4 md:text-6xl"
>

View File

@ -56,7 +56,7 @@
<meta name="description" content={lines.join('')} />
</svelte:head>
<div
class="container flex flex-col items-center justify-center gap-y-2.5 py-32 md:gap-y-8 md:px-24 md:py-32"
class="content-container flex flex-col items-center justify-center gap-y-2.5 py-32 md:gap-y-8 md:px-24 md:py-32"
>
<div
bind:this={element}

View File

@ -2,7 +2,9 @@
import TitleScreenAnimatedTags from '$lib/home/framework/ui/TitleScreenAnimatedTags.svelte';
</script>
<div class="container flex min-h-content-height flex-col justify-center gap-y-2.5 md:gap-y-8">
<div
class="content-container flex min-h-content-height flex-col justify-center gap-y-2.5 md:gap-y-8"
>
<h2 class="text-3xl font-bold text-gray-800 md:text-6xl">Hello 大家好!</h2>
<h1 class="flex flex-row items-center gap-x-2 text-4xl font-extrabold text-gray-800 md:text-7xl">
<span>我是</span>

View File

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

View File

@ -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,
});
}
}

View File

@ -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),
});
}
}

View File

@ -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<ImageInfoViewModel>;
export type ImageEvent = ImageUploadedEvent;
export class ImageBloc {
private readonly state = writable<ImageInfoState>({
status: StatusType.Idle,
});
constructor(private readonly uploadImageUseCase: UploadImageUseCase) {}
get subscribe() {
return this.state.subscribe;
}
async dispatch(event: ImageEvent): Promise<ImageInfoState> {
switch (event.event) {
case ImageEventType.ImageUploadedEvent:
return this.uploadImage(event.file);
}
}
private async uploadImage(file: File): Promise<ImageInfoState> {
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
let result: ImageInfoState;
try {
const imageInfo = await this.uploadImageUseCase.execute(file);
const imageInfoViewModel = ImageInfoViewModel.fromEntity(imageInfo);
result = { status: StatusType.Success, data: imageInfoViewModel };
} catch (error) {
result = { status: StatusType.Error, error: error as Error };
}
return result;
}
}
export enum ImageEventType {
ImageUploadedEvent,
}
interface ImageUploadedEvent {
event: ImageEventType.ImageUploadedEvent;
file: File;
}

View File

@ -0,0 +1,39 @@
import 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;
}

View File

@ -0,0 +1,5 @@
import type { ImageInfo } from '$lib/image/domain/entity/imageInfo';
export interface ImageRepository {
uploadImage(file: File): Promise<ImageInfo>;
}

View File

@ -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);
}
}

View 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;
}
}

View 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);
}
}

View File

@ -0,0 +1,47 @@
<script lang="ts">
import { getContext } from 'svelte';
import UploadImageDialoag from './UploadImageDialoag.svelte';
import { ImageBloc, ImageEventType } from '$lib/image/adapter/presenter/imageBloc';
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
import { 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: boolean;
try {
await navigator.clipboard.writeText(imageInfo.url.href);
copiedToClipboard = true;
} catch {
copiedToClipboard = false;
}
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>
<div class="dashboard-container mb-10">
<div class="flex flex-row items-center justify-between">
<h1 class="py-16 text-5xl font-bold text-gray-800">Image</h1>
<UploadImageDialoag disabled={isLoading} onSubmit={onUploadImageDialogSubmit} />
</div>
<p>Gallery is currently unavailable.</p>
</div>

View File

@ -0,0 +1,83 @@
<script lang="ts">
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 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: 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 fileInputErrorMessage: string | null = $state(null);
const isFileInputError = $derived(fileInputErrorMessage !== null);
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
fileInputErrorMessage = null;
const file = files?.[0];
if (!file || !imageMimeTypes.includes(file.type)) {
fileInputErrorMessage = 'Please select an valid image file.';
return;
}
await uploadImage(file);
close();
files = undefined;
fileInputErrorMessage = null;
}
function close() {
open = false;
}
</script>
<Dialog {open} onOpenChange={(val) => (open = val)}>
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Upload</DialogTrigger>
<DialogContent
showCloseButton={false}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeydown={(e) => e.preventDefault()}
>
<DialogHeader class="mb-4">
<DialogTitle>Upload Image</DialogTitle>
</DialogHeader>
<form id="upload-form" onsubmit={onSubmit}>
<Label for="file-input" class="pb-2">
{`Image File (${imageMimeTypes.join(', ')})`}
</Label>
<Input
id="file-input"
type="file"
accept={imageMimeTypes.join(',')}
aria-invalid={isFileInputError}
class="cursor-pointer"
bind:files
{disabled}
/>
{#if isFileInputError}
<p class="text-sm text-red-500">{fileInputErrorMessage}</p>
{/if}
</form>
<DialogFooter class="mt-6">
<Button variant="outline" onclick={close} {disabled}>Cancel</Button>
<Button type="submit" form="upload-form" {disabled}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

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

View File

@ -1,6 +1,6 @@
<script lang="ts">
import type { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import Label from '$lib/post/framework/ui/Label.svelte';
import PostLabel from '$lib/post/framework/ui/PostLabel.svelte';
const { postInfo }: { postInfo: PostInfoViewModel } = $props();
</script>
@ -8,7 +8,7 @@
<div class="flex flex-col pt-9 md:pt-20">
<div class="mb-4 flex flex-row gap-2">
{#each postInfo.labels as label (label.id)}
<Label {label} />
<PostLabel {label} />
{/each}
</div>
<h1 class="text-3xl font-bold text-gray-800 sm:text-4xl md:text-5xl">{postInfo.title}</h1>

View File

@ -32,7 +32,7 @@
{/if}
{/if}
</svelte:head>
<article class="container prose pb-10 prose-gray">
<article class="content-container prose pb-10 prose-gray">
{#if state.data}
<PostContentHeader postInfo={state.data.info} />
<div class="max-w-3xl">

View File

@ -17,7 +17,7 @@
content="探索 魚之魷魂 SquidSpirit 的所有文章,這裡是您尋找最新技術洞見與實用教學的園地。"
/>
</svelte:head>
<div class="container pb-10">
<div class="content-container pb-10">
<h1 class="py-9 text-center text-3xl font-bold text-gray-800 md:py-20 md:text-5xl">文章</h1>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-y-8 lg:grid-cols-3">
{#each state.data ?? [] as postInfo (postInfo.id)}

View File

@ -1,13 +1,13 @@
<script lang="ts">
import type { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel';
import Label from '$lib/post/framework/ui/Label.svelte';
import PostLabel from '$lib/post/framework/ui/PostLabel.svelte';
const { labels }: { labels: readonly LabelViewModel[] } = $props();
</script>
<div class="flex flex-row gap-x-2">
{#each labels.slice(0, 2) as label (label.id)}
<Label {label} />
<PostLabel {label} />
{/each}
{#if labels.length > 2}
<div class="rounded-full bg-gray-200 px-2 py-0.5 text-xs">

View File

@ -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';
</script>
<GoogleAnalytics />
@ -11,6 +12,7 @@
<meta name="app-version" content={App.__VERSION__} />
</svelte:head>
<div class="min-h-screen">
<Toaster theme="light" />
<Navbar />
<main>
<slot />

View File

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

View File

@ -1 +1,19 @@
<div>Image</div>
<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 { 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>
<ImageManagementPage />