feat(frontend): avatar cropping

This commit is contained in:
sam 2025-02-24 21:32:20 +01:00
parent f1f777ff82
commit 64ea25e89e
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
4 changed files with 108 additions and 10 deletions

View file

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

View file

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

View file

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

View file

@ -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": {