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/adapter-node": "^5.2.10",
|
||||||
"@sveltejs/kit": "^2.12.1",
|
"@sveltejs/kit": "^2.12.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
||||||
"@sveltestrap/sveltestrap": "^6.2.7",
|
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
@ -31,6 +31,7 @@
|
||||||
"svelte": "^5.14.3",
|
"svelte": "^5.14.3",
|
||||||
"svelte-bootstrap-icons": "^3.1.1",
|
"svelte-bootstrap-icons": "^3.1.1",
|
||||||
"svelte-check": "^4.1.1",
|
"svelte-check": "^4.1.1",
|
||||||
|
"svelte-easy-crop": "^4.0.0",
|
||||||
"sveltekit-i18n": "^2.4.2",
|
"sveltekit-i18n": "^2.4.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.18.1",
|
"typescript-eslint": "^8.18.1",
|
||||||
|
|
|
@ -55,8 +55,8 @@ importers:
|
||||||
specifier: ^5.0.2
|
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))
|
version: 5.0.2(svelte@5.14.3)(vite@6.0.3(@types/node@22.12.0)(sass@1.83.0))
|
||||||
'@sveltestrap/sveltestrap':
|
'@sveltestrap/sveltestrap':
|
||||||
specifier: ^6.2.7
|
specifier: ^7.1.0
|
||||||
version: 6.2.7(svelte@5.14.3)
|
version: 7.1.0(svelte@5.14.3)
|
||||||
'@types/eslint':
|
'@types/eslint':
|
||||||
specifier: ^9.6.1
|
specifier: ^9.6.1
|
||||||
version: 9.6.1
|
version: 9.6.1
|
||||||
|
@ -102,6 +102,9 @@ importers:
|
||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.1.1
|
specifier: ^4.1.1
|
||||||
version: 4.1.1(picomatch@4.0.2)(svelte@5.14.3)(typescript@5.7.2)
|
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:
|
sveltekit-i18n:
|
||||||
specifier: ^2.4.2
|
specifier: ^2.4.2
|
||||||
version: 2.4.2(svelte@5.14.3)
|
version: 2.4.2(svelte@5.14.3)
|
||||||
|
@ -1002,8 +1005,8 @@ packages:
|
||||||
'@sveltekit-i18n/parser-default@1.1.1':
|
'@sveltekit-i18n/parser-default@1.1.1':
|
||||||
resolution: {integrity: sha512-/gtzLlqm/sox7EoPKD56BxGZktK/syGc79EbJAPWY5KVitQD9SM0TP8yJCqDxTVPk7Lk0WJhrBGUE2Nn0f5M1w==}
|
resolution: {integrity: sha512-/gtzLlqm/sox7EoPKD56BxGZktK/syGc79EbJAPWY5KVitQD9SM0TP8yJCqDxTVPk7Lk0WJhrBGUE2Nn0f5M1w==}
|
||||||
|
|
||||||
'@sveltestrap/sveltestrap@6.2.7':
|
'@sveltestrap/sveltestrap@7.1.0':
|
||||||
resolution: {integrity: sha512-WwLLfAFUb42BGuRrf3Vbct30bQMzlEMMipN/MfxhjuLTmLQeW9muVJfPyvjtWS+mY+RjkSCoHvAp/ZobP1NLlQ==}
|
resolution: {integrity: sha512-TpIx25kqLV+z+VD3yfqYayOI1IaCeWFbT0uqM6NfA4vQgDs9PjFwmjkU4YEAlV/ngs9e7xPmaRWE7lkrg4Miow==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^4.0.0 || ^5.0.0 || ^5.0.0-next.0
|
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
|
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||||
typescript: '>=5.0.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:
|
svelte-eslint-parser@0.43.0:
|
||||||
resolution: {integrity: sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==}
|
resolution: {integrity: sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
@ -3079,7 +3087,7 @@ snapshots:
|
||||||
|
|
||||||
'@sveltekit-i18n/parser-default@1.1.1': {}
|
'@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:
|
dependencies:
|
||||||
'@popperjs/core': 2.11.8
|
'@popperjs/core': 2.11.8
|
||||||
svelte: 5.14.3
|
svelte: 5.14.3
|
||||||
|
@ -4051,6 +4059,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- picomatch
|
- 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):
|
svelte-eslint-parser@0.43.0(svelte@5.14.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint-scope: 7.2.2
|
eslint-scope: 7.2.2
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Avatar from "$components/Avatar.svelte";
|
import Avatar from "$components/Avatar.svelte";
|
||||||
import { t } from "$lib/i18n";
|
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 { encode } from "base64-arraybuffer";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
|
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
|
||||||
|
@ -21,6 +22,7 @@
|
||||||
let avatar: string = $state("");
|
let avatar: string = $state("");
|
||||||
let avatarExists = $derived(avatar !== "");
|
let avatarExists = $derived(avatar !== "");
|
||||||
let avatarTooLarge = $derived(avatar !== "" && avatar.length > MAX_AVATAR_BYTES);
|
let avatarTooLarge = $derived(avatar !== "" && avatar.length > MAX_AVATAR_BYTES);
|
||||||
|
let cropperOpen = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
getAvatar(avatarFiles);
|
getAvatar(avatarFiles);
|
||||||
|
@ -28,7 +30,7 @@
|
||||||
|
|
||||||
const getAvatar = async (list: FileList | null) => {
|
const getAvatar = async (list: FileList | null) => {
|
||||||
if (!list || list.length === 0) {
|
if (!list || list.length === 0) {
|
||||||
avatar = "";
|
uncroppedAvatar = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,7 +38,47 @@
|
||||||
const base64 = encode(buffer);
|
const base64 = encode(buffer);
|
||||||
|
|
||||||
const uri = `data:${list[0].type};base64,${base64}`;
|
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>
|
</script>
|
||||||
|
|
||||||
|
@ -44,6 +86,41 @@
|
||||||
<Avatar {name} url={avatarExists ? avatar : current} {alt} />
|
<Avatar {name} url={avatarExists ? avatar : current} {alt} />
|
||||||
</p>
|
</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">
|
<InputGroup class="mb-2">
|
||||||
<input
|
<input
|
||||||
class="form-control"
|
class="form-control"
|
||||||
|
@ -79,3 +156,9 @@
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cropper-wrapper {
|
||||||
|
min-height: 30em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -291,7 +291,9 @@
|
||||||
"custom-preference-muted": "Show as muted text",
|
"custom-preference-muted": "Show as muted text",
|
||||||
"custom-preference-favourite": "Treat like favourite",
|
"custom-preference-favourite": "Treat like favourite",
|
||||||
"custom-preference-notice": "Want to edit your custom preferences?",
|
"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",
|
"cancel": "Cancel",
|
||||||
"report": {
|
"report": {
|
||||||
|
|
Loading…
Reference in a new issue