feat: Add profile images

This commit is contained in:
2026-04-17 03:05:47 +02:00
parent 35b616fb77
commit 17738ec484
49 changed files with 2622 additions and 89 deletions

View File

@@ -0,0 +1,38 @@
import {
PROFILE_AVATAR_MAX_ZOOM,
PROFILE_AVATAR_MIN_ZOOM,
clampProfileAvatarTransform,
clampProfileAvatarZoom,
resolveProfileAvatarStorageFileName,
resolveProfileAvatarBaseScale
} from './profile-avatar.models';
describe('profile-avatar models', () => {
it('clamps zoom inside allowed range', () => {
expect(clampProfileAvatarZoom(0.1)).toBe(PROFILE_AVATAR_MIN_ZOOM);
expect(clampProfileAvatarZoom(9)).toBe(PROFILE_AVATAR_MAX_ZOOM);
expect(clampProfileAvatarZoom(2.5)).toBe(2.5);
});
it('resolves cover scale for portrait images', () => {
expect(resolveProfileAvatarBaseScale({ width: 200, height: 400 }, 224)).toBeCloseTo(1.12);
});
it('clamps transform offsets so image still covers crop frame', () => {
const transform = clampProfileAvatarTransform(
{ width: 320, height: 240 },
{ zoom: 1, offsetX: 500, offsetY: -500 },
224
);
expect(transform.offsetX).toBeCloseTo(37.333333, 4);
expect(transform.offsetY).toBe(0);
});
it('maps avatar mime types to storage file names', () => {
expect(resolveProfileAvatarStorageFileName('image/gif')).toBe('profile.gif');
expect(resolveProfileAvatarStorageFileName('image/jpeg')).toBe('profile.jpg');
expect(resolveProfileAvatarStorageFileName('image/webp')).toBe('profile.webp');
expect(resolveProfileAvatarStorageFileName(undefined)).toBe('profile.webp');
});
});

View File

@@ -0,0 +1,99 @@
export const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [
'image/webp',
'image/gif',
'image/jpeg'
] as const;
export const PROFILE_AVATAR_ACCEPT_ATTRIBUTE = '.webp,.gif,.jpg,.jpeg,image/webp,image/gif,image/jpeg';
export const PROFILE_AVATAR_OUTPUT_SIZE = 256;
export const PROFILE_AVATAR_EDITOR_FRAME_SIZE = 224;
export const PROFILE_AVATAR_MIN_ZOOM = 1;
export const PROFILE_AVATAR_MAX_ZOOM = 4;
export interface ProfileAvatarDimensions {
width: number;
height: number;
}
export interface EditableProfileAvatarSource extends ProfileAvatarDimensions {
file: File;
objectUrl: string;
mime: string;
name: string;
preservesAnimation: boolean;
}
export interface ProfileAvatarTransform {
zoom: number;
offsetX: number;
offsetY: number;
}
export interface ProfileAvatarUpdates {
avatarUrl: string;
avatarHash: string;
avatarMime: string;
avatarUpdatedAt: number;
}
export interface ProcessedProfileAvatar extends ProfileAvatarUpdates, ProfileAvatarDimensions {
base64: string;
blob: Blob;
}
export function resolveProfileAvatarStorageFileName(mime: string | null | undefined): string {
switch (mime?.toLowerCase()) {
case 'image/gif':
return 'profile.gif';
case 'image/jpeg':
case 'image/jpg':
return 'profile.jpg';
default:
return 'profile.webp';
}
}
export function clampProfileAvatarZoom(zoom: number): number {
if (!Number.isFinite(zoom)) {
return PROFILE_AVATAR_MIN_ZOOM;
}
return Math.min(Math.max(zoom, PROFILE_AVATAR_MIN_ZOOM), PROFILE_AVATAR_MAX_ZOOM);
}
export function resolveProfileAvatarBaseScale(
source: ProfileAvatarDimensions,
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
): number {
return Math.max(frameSize / source.width, frameSize / source.height);
}
export function clampProfileAvatarTransform(
source: ProfileAvatarDimensions,
transform: ProfileAvatarTransform,
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
): ProfileAvatarTransform {
const zoom = clampProfileAvatarZoom(transform.zoom);
const renderedWidth = source.width * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
const renderedHeight = source.height * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
const maxOffsetX = Math.max(0, (renderedWidth - frameSize) / 2);
const maxOffsetY = Math.max(0, (renderedHeight - frameSize) / 2);
return {
zoom,
offsetX: clampOffset(transform.offsetX, maxOffsetX),
offsetY: clampOffset(transform.offsetY, maxOffsetY)
};
}
function clampOffset(value: number, maxMagnitude: number): number {
if (!Number.isFinite(value)) {
return 0;
}
const nextValue = Math.min(Math.max(value, -maxMagnitude), maxMagnitude);
return Object.is(nextValue, -0) ? 0 : nextValue;
}