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:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import type { Album, AlbumMember } from '$lib/types';
|
||||
@@ -101,8 +102,10 @@
|
||||
</li>
|
||||
{#each members as member (member.user_id)}
|
||||
<li class="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<span class="text-text"
|
||||
>{member.user.name} <span class="text-text-muted text-sm">({member.user.email})</span></span
|
||||
<span class="text-text inline-flex items-center gap-2"
|
||||
><Avatar name={member.user.name} url={member.user.avatar_url} size="size-7" />
|
||||
{member.user.name}
|
||||
<span class="text-text-muted text-sm">({member.user.email})</span></span
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if assignableRoles.length > 0}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user