feat(frontend): avatar cropping
This commit is contained in:
		
							parent
							
								
									f1f777ff82
								
							
						
					
					
						commit
						64ea25e89e
					
				
					 4 changed files with 108 additions and 10 deletions
				
			
		|  | @ -15,7 +15,7 @@ | |||
| 		"@sveltejs/adapter-node": "^5.2.10", | ||||
| 		"@sveltejs/kit": "^2.12.1", | ||||
| 		"@sveltejs/vite-plugin-svelte": "^5.0.2", | ||||
| 		"@sveltestrap/sveltestrap": "^6.2.7", | ||||
| 		"@sveltestrap/sveltestrap": "^7.1.0", | ||||
| 		"@types/eslint": "^9.6.1", | ||||
| 		"@types/luxon": "^3.4.2", | ||||
| 		"@types/markdown-it": "^14.1.2", | ||||
|  | @ -31,6 +31,7 @@ | |||
| 		"svelte": "^5.14.3", | ||||
| 		"svelte-bootstrap-icons": "^3.1.1", | ||||
| 		"svelte-check": "^4.1.1", | ||||
| 		"svelte-easy-crop": "^4.0.0", | ||||
| 		"sveltekit-i18n": "^2.4.2", | ||||
| 		"typescript": "^5.7.2", | ||||
| 		"typescript-eslint": "^8.18.1", | ||||
|  |  | |||
							
								
								
									
										22
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										22
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							|  | @ -55,8 +55,8 @@ importers: | |||
|         specifier: ^5.0.2 | ||||
|         version: 5.0.2(svelte@5.14.3)(vite@6.0.3(@types/node@22.12.0)(sass@1.83.0)) | ||||
|       '@sveltestrap/sveltestrap': | ||||
|         specifier: ^6.2.7 | ||||
|         version: 6.2.7(svelte@5.14.3) | ||||
|         specifier: ^7.1.0 | ||||
|         version: 7.1.0(svelte@5.14.3) | ||||
|       '@types/eslint': | ||||
|         specifier: ^9.6.1 | ||||
|         version: 9.6.1 | ||||
|  | @ -102,6 +102,9 @@ importers: | |||
|       svelte-check: | ||||
|         specifier: ^4.1.1 | ||||
|         version: 4.1.1(picomatch@4.0.2)(svelte@5.14.3)(typescript@5.7.2) | ||||
|       svelte-easy-crop: | ||||
|         specifier: ^4.0.0 | ||||
|         version: 4.0.0(svelte@5.14.3) | ||||
|       sveltekit-i18n: | ||||
|         specifier: ^2.4.2 | ||||
|         version: 2.4.2(svelte@5.14.3) | ||||
|  | @ -1002,8 +1005,8 @@ packages: | |||
|   '@sveltekit-i18n/parser-default@1.1.1': | ||||
|     resolution: {integrity: sha512-/gtzLlqm/sox7EoPKD56BxGZktK/syGc79EbJAPWY5KVitQD9SM0TP8yJCqDxTVPk7Lk0WJhrBGUE2Nn0f5M1w==} | ||||
| 
 | ||||
|   '@sveltestrap/sveltestrap@6.2.7': | ||||
|     resolution: {integrity: sha512-WwLLfAFUb42BGuRrf3Vbct30bQMzlEMMipN/MfxhjuLTmLQeW9muVJfPyvjtWS+mY+RjkSCoHvAp/ZobP1NLlQ==} | ||||
|   '@sveltestrap/sveltestrap@7.1.0': | ||||
|     resolution: {integrity: sha512-TpIx25kqLV+z+VD3yfqYayOI1IaCeWFbT0uqM6NfA4vQgDs9PjFwmjkU4YEAlV/ngs9e7xPmaRWE7lkrg4Miow==} | ||||
|     peerDependencies: | ||||
|       svelte: ^4.0.0 || ^5.0.0 || ^5.0.0-next.0 | ||||
| 
 | ||||
|  | @ -1967,6 +1970,11 @@ packages: | |||
|       svelte: ^4.0.0 || ^5.0.0-next.0 | ||||
|       typescript: '>=5.0.0' | ||||
| 
 | ||||
|   svelte-easy-crop@4.0.0: | ||||
|     resolution: {integrity: sha512-/asrrCYypXwCsPqJ07m7s7QArJwrdfEt7D1UN9hC4WF3GgEtuqmGuVi5DGeJVtBpKu5388gYFtCgQz9lA+/Rtg==} | ||||
|     peerDependencies: | ||||
|       svelte: ^4.0.0 || ^5.0.0 | ||||
| 
 | ||||
|   svelte-eslint-parser@0.43.0: | ||||
|     resolution: {integrity: sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==} | ||||
|     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | ||||
|  | @ -3079,7 +3087,7 @@ snapshots: | |||
| 
 | ||||
|   '@sveltekit-i18n/parser-default@1.1.1': {} | ||||
| 
 | ||||
|   '@sveltestrap/sveltestrap@6.2.7(svelte@5.14.3)': | ||||
|   '@sveltestrap/sveltestrap@7.1.0(svelte@5.14.3)': | ||||
|     dependencies: | ||||
|       '@popperjs/core': 2.11.8 | ||||
|       svelte: 5.14.3 | ||||
|  | @ -4051,6 +4059,10 @@ snapshots: | |||
|     transitivePeerDependencies: | ||||
|       - picomatch | ||||
| 
 | ||||
|   svelte-easy-crop@4.0.0(svelte@5.14.3): | ||||
|     dependencies: | ||||
|       svelte: 5.14.3 | ||||
| 
 | ||||
|   svelte-eslint-parser@0.43.0(svelte@5.14.3): | ||||
|     dependencies: | ||||
|       eslint-scope: 7.2.2 | ||||
|  |  | |||
|  | @ -1,7 +1,8 @@ | |||
| <script lang="ts"> | ||||
| 	import Avatar from "$components/Avatar.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { Icon, InputGroup } from "@sveltestrap/sveltestrap"; | ||||
| 	import { Icon, InputGroup, Modal } from "@sveltestrap/sveltestrap"; | ||||
| 	import Cropper, { type CropArea, type OnCropCompleteEvent } from "svelte-easy-crop"; | ||||
| 	import { encode } from "base64-arraybuffer"; | ||||
| 	import prettyBytes from "pretty-bytes"; | ||||
| 	import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte"; | ||||
|  | @ -21,6 +22,7 @@ | |||
| 	let avatar: string = $state(""); | ||||
| 	let avatarExists = $derived(avatar !== ""); | ||||
| 	let avatarTooLarge = $derived(avatar !== "" && avatar.length > MAX_AVATAR_BYTES); | ||||
| 	let cropperOpen = $state(false); | ||||
| 
 | ||||
| 	$effect(() => { | ||||
| 		getAvatar(avatarFiles); | ||||
|  | @ -28,7 +30,7 @@ | |||
| 
 | ||||
| 	const getAvatar = async (list: FileList | null) => { | ||||
| 		if (!list || list.length === 0) { | ||||
| 			avatar = ""; | ||||
| 			uncroppedAvatar = ""; | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
|  | @ -36,7 +38,47 @@ | |||
| 		const base64 = encode(buffer); | ||||
| 
 | ||||
| 		const uri = `data:${list[0].type};base64,${base64}`; | ||||
| 		avatar = uri; | ||||
| 		uncroppedAvatar = uri; | ||||
| 		cropperOpen = true; | ||||
| 	}; | ||||
| 
 | ||||
| 	let uncroppedAvatar: string = $state(""); | ||||
| 	let crop = $state({ x: 0, y: 0 }); | ||||
| 	let zoom = $state(1); | ||||
| 	let croppedArea = $state({ x: 0, y: 0, height: 0, width: 0 } satisfies CropArea); | ||||
| 
 | ||||
| 	const onCropComplete = (e: OnCropCompleteEvent) => { | ||||
| 		croppedArea = e.pixels; | ||||
| 	}; | ||||
| 
 | ||||
| 	const doCrop = () => { | ||||
| 		cropperOpen = false; | ||||
| 		if (!uncroppedAvatar) { | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		const canvas = document.createElement("canvas"); | ||||
| 		const ctx = canvas.getContext("2d"); | ||||
| 		const img = new Image(); | ||||
| 		img.onload = () => { | ||||
| 			canvas.width = croppedArea.width; | ||||
| 			canvas.height = croppedArea.height; | ||||
| 
 | ||||
| 			ctx?.drawImage( | ||||
| 				img, | ||||
| 				croppedArea.x, | ||||
| 				croppedArea.y, | ||||
| 				croppedArea.width, | ||||
| 				croppedArea.height, | ||||
| 				0, | ||||
| 				0, | ||||
| 				croppedArea.width, | ||||
| 				croppedArea.height, | ||||
| 			); | ||||
| 
 | ||||
| 			avatar = canvas.toDataURL("image/webp", 1); | ||||
| 		}; | ||||
| 		img.src = uncroppedAvatar; | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
|  | @ -44,6 +86,41 @@ | |||
| 	<Avatar {name} url={avatarExists ? avatar : current} {alt} /> | ||||
| </p> | ||||
| 
 | ||||
| <Modal | ||||
| 	isOpen={cropperOpen} | ||||
| 	autoFocus | ||||
| 	backdrop | ||||
| 	fade | ||||
| 	keyboard | ||||
| 	returnFocusAfterClose | ||||
| 	toggle={() => (cropperOpen = !cropperOpen)} | ||||
| > | ||||
| 	<div class="modal-header"> | ||||
| 		<h1 class="modal-title fs-5">{$t("editor.crop-avatar-header")}</h1> | ||||
| 	</div> | ||||
| 	<div class="modal-body cropper-wrapper"> | ||||
| 		{#if uncroppedAvatar} | ||||
| 			<Cropper | ||||
| 				image={uncroppedAvatar} | ||||
| 				{crop} | ||||
| 				{zoom} | ||||
| 				minZoom={1} | ||||
| 				maxZoom={4} | ||||
| 				aspect={1 / 1} | ||||
| 				oncropcomplete={onCropComplete} | ||||
| 			/> | ||||
| 		{/if} | ||||
| 	</div> | ||||
| 	<div class="modal-footer"> | ||||
| 		<button type="button" class="btn btn-primary" onclick={() => doCrop()}> | ||||
| 			{$t("editor.crop-avatar-button")} | ||||
| 		</button> | ||||
| 		<button type="button" class="btn btn-outline-secondary" onclick={() => (cropperOpen = false)}> | ||||
| 			{$t("cancel")} | ||||
| 		</button> | ||||
| 	</div> | ||||
| </Modal> | ||||
| 
 | ||||
| <InputGroup class="mb-2"> | ||||
| 	<input | ||||
| 		class="form-control" | ||||
|  | @ -79,3 +156,9 @@ | |||
| 		})} | ||||
| 	</p> | ||||
| {/if} | ||||
| 
 | ||||
| <style> | ||||
| 	.cropper-wrapper { | ||||
| 		min-height: 30em; | ||||
| 	} | ||||
| </style> | ||||
|  |  | |||
|  | @ -291,7 +291,9 @@ | |||
| 		"custom-preference-muted": "Show as muted text", | ||||
| 		"custom-preference-favourite": "Treat like favourite", | ||||
| 		"custom-preference-notice": "Want to edit your custom preferences?", | ||||
| 		"custom-preference-notice-link": "Go to settings" | ||||
| 		"custom-preference-notice-link": "Go to settings", | ||||
| 		"crop-avatar-header": "Crop avatar", | ||||
| 		"crop-avatar-button": "Crop" | ||||
| 	}, | ||||
| 	"cancel": "Cancel", | ||||
| 	"report": { | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue