feat(profile): add round avatar with upload and cropping

- backend: user_avatar_objects binding (PG+SQLite), GC preserves avatar blobs,
  avatar service (EXIF-stripping WebP 128/512), public avatar read route
- web: avatar upload/crop on profile, round avatar in header/members/comments/author
- android: avatar upload/crop, round avatar on profile and albums header
- openspec: add-user-avatar proposal/design/specs/tasks
This commit is contained in:
iu
2026-10-12 16:16:47 +03:00
parent 657a04309e
commit 04617d1cdb
49 changed files with 2362 additions and 21 deletions
+85 -2
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authApi } from '$lib/api';
import Avatar from '$lib/components/Avatar.svelte';
import AvatarCropper from '$lib/components/AvatarCropper.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
@@ -11,6 +13,8 @@
let currentPassword = $state('');
let newPassword = $state('');
let deletePassword = $state('');
let pickedFile = $state<File | null>(null);
let avatarBusy = $state(false);
let error = $state('');
let notice = $state('');
@@ -34,6 +38,44 @@
if (updated) auth.setUser(updated);
}
function onPickFile(ev: Event) {
const input = ev.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) pickedFile = file;
}
async function onCropConfirm(blob: Blob) {
avatarBusy = true;
error = '';
notice = '';
try {
const data = await authApi.uploadAvatar(blob);
applyUser(data.user);
pickedFile = null;
notice = 'Аватар обновлён';
} catch (e) {
error = (e as Error).message;
} finally {
avatarBusy = false;
}
}
async function removeAvatar() {
avatarBusy = true;
error = '';
notice = '';
try {
const data = await authApi.removeAvatar();
applyUser(data.user);
notice = 'Аватар удалён';
} catch (e) {
error = (e as Error).message;
} finally {
avatarBusy = false;
}
}
async function saveName(ev: Event) {
ev.preventDefault();
error = '';
@@ -75,8 +117,7 @@
}
}
async function removeAccount() {
error = '';
async function removeAccount() { error = '';
if (!confirm('Удалить аккаунт со всем содержимым? Действие необратимо.')) return;
try {
await authApi.deleteAccount(deletePassword);
@@ -99,6 +140,40 @@
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
{#if notice}<p class="text-success mb-4 text-sm">{notice}</p>{/if}
<div class="border-border bg-bg-elevated mb-6 rounded-xl border p-4">
<h2 class="mb-3 font-semibold">Фото профиля</h2>
<div class="flex items-center gap-4">
<Avatar name={user?.name} url={user?.avatar_url} size="size-20" />
<div class="flex flex-wrap gap-2">
<label
class="bg-primary text-primary-fg inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
>
<Icon name="photo_camera" class="size-4" />
{user?.avatar_url ? 'Заменить фото' : 'Загрузить фото'}
<input
type="file"
accept="image/*"
class="hidden"
disabled={avatarBusy}
onchange={onPickFile}
/>
</label>
{#if user?.avatar_url}
<button
type="button"
class="border-danger text-danger inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm disabled:opacity-50"
disabled={avatarBusy}
onclick={removeAvatar}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
{/if}
</div>
</div>
<p class="text-text-muted mt-2 text-xs">Квадратное изображение; можно выбрать область кадрирования.</p>
</div>
<div class="border-border bg-bg-elevated mb-6 rounded-xl border p-4">
<h2 class="mb-2 font-semibold">Идентификаторы</h2>
<ul class="text-text-muted space-y-1 text-sm">
@@ -224,3 +299,11 @@
Удалить аккаунт
</button>
</div>
{#if pickedFile}
<AvatarCropper
file={pickedFile}
onCancel={() => (pickedFile = null)}
onConfirm={onCropConfirm}
/>
{/if}