diff --git a/frontend/src/context/AuthContext.test.tsx b/frontend/src/context/AuthContext.test.tsx index 47fc67281..c1a4c43f6 100644 --- a/frontend/src/context/AuthContext.test.tsx +++ b/frontend/src/context/AuthContext.test.tsx @@ -88,6 +88,26 @@ test('updateProfile sends auth header and updates user', async () => { expect(global.testAuth.currentUser).toEqual({ name: 'Updated' }); }); +test('updateProfile leaves auth loading false while the profile write is pending', async () => { + axios.get.mockResolvedValue({ data: { name: 'Init' } }); + axios.post.mockResolvedValueOnce({ data: { token: 'tok', user: { name: 'Init' } } }); + await act(async () => { await global.testAuth.login('a', 'b'); }); + + let finishUpdate: (() => void) | undefined; + axios.put.mockReturnValueOnce(new Promise((resolve) => { + finishUpdate = () => resolve({ data: { name: 'Updated' } }); + })); + let update: Promise; + act(() => { update = global.testAuth.updateProfile({ foo: 'bar' }); }); + + expect(global.testAuth.loading).toBe(false); + await act(async () => { + finishUpdate?.(); + await update; + }); + expect(global.testAuth.loading).toBe(false); +}); + test('updateProfile retains multipart support for a file payload', async () => { axios.post.mockResolvedValueOnce({ data: { token: 'tok', user: { name: 'Init' } } }); await act(async () => { await global.testAuth.login('a', 'b'); }); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 23dd0639c..2d782bfbd 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -149,7 +149,6 @@ export const AuthProvider: React.FC = ({ children }) => { const updateProfile = async (profile: FormData | Record): Promise => { try { - setLoading(true); const headers: Record = { Authorization: `Bearer ${token}` }; if (profile instanceof FormData) headers['Content-Type'] = 'multipart/form-data'; const res = await axios.put('/api/users/profile', profile, { @@ -162,8 +161,6 @@ export const AuthProvider: React.FC = ({ children }) => { const e = err as { response?: { data?: { msg?: string } } }; setError(e.response?.data?.msg || 'Failed to update profile'); throw err; - } finally { - setLoading(false); } }; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6eb054a52..190c531a4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -29,6 +29,32 @@ "text": "Checking your access…" } }, + "settings": { + "avatar": { + "regenerate": "Regenerate", + "uploadPhoto": "Upload photo", + "description": "No face and no skin tone until you choose one. Regenerate for another, or upload a photo.", + "descriptionAfterChoice": "Regenerate for another, or upload a photo.", + "uploadTitle": "Upload photo", + "cropPreview": "Avatar crop preview", + "loadingImage": "Loading image…", + "zoom": "Zoom", + "cropInstructions": "Drag to place it. The square is what everyone sees.", + "previewSizes": "Avatar size previews", + "previewAt": "{{size}} px", + "imageTypeRequired": "Choose an image file.", + "imageCouldNotLoad": "This image could not be loaded. Choose another image.", + "cropCouldNotSave": "This image could not be prepared. Try again.", + "cancel": "Cancel", + "savePhoto": "Save photo", + "savingPhoto": "Saving…", + "photoSaved": "Photo saved.", + "regenerated": "Avatar changed.", + "uploadFailed": "Could not upload your photo. Try again.", + "profileSaveFailed": "Could not save your avatar. Try again.", + "regenerateFailed": "Could not change your avatar. Try again." + } + }, "activity": { "title": "Activity", "subtitle": "what needs you, then what moved", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index eae41cea1..c1bd6de3c 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -29,6 +29,32 @@ "text": "正在检查你的访问权限…" } }, + "settings": { + "avatar": { + "regenerate": "换一个", + "uploadPhoto": "上传照片", + "description": "选择前不会显示面部或肤色。你可以换一个造型,或上传照片。", + "descriptionAfterChoice": "你可以换一个造型,或上传照片。", + "uploadTitle": "上传照片", + "cropPreview": "头像裁剪预览", + "loadingImage": "正在加载图片…", + "zoom": "缩放", + "cropInstructions": "拖动图片调整位置。方框中的部分会显示为头像。", + "previewSizes": "头像尺寸预览", + "previewAt": "{{size}}像素", + "imageTypeRequired": "请选择图片文件。", + "imageCouldNotLoad": "无法加载这张图片,请选择其他图片。", + "cropCouldNotSave": "无法处理这张图片,请重试。", + "cancel": "取消", + "savePhoto": "保存照片", + "savingPhoto": "正在保存…", + "photoSaved": "照片已保存。", + "regenerated": "头像已更换。", + "uploadFailed": "无法上传照片,请重试。", + "profileSaveFailed": "无法保存头像,请重试。", + "regenerateFailed": "无法更换头像,请重试。" + } + }, "activity": { "title": "动态", "subtitle": "需要你处理的事项,以及刚刚推进的工作", diff --git a/frontend/src/utils/avatarUtils.ts b/frontend/src/utils/avatarUtils.ts index 9518c2457..23c06d7a9 100644 --- a/frontend/src/utils/avatarUtils.ts +++ b/frontend/src/utils/avatarUtils.ts @@ -1,6 +1,8 @@ import { normalizeUploadUrl } from './apiBaseUrl'; // eslint-disable-next-line import/no-cycle -import { characterAvatarFor, AvatarKind, PICKER_CELL_COUNT } from '../v2/utils/avatars'; +import { + characterAvatarFor, paperAvatarFor, AvatarKind, PICKER_CELL_COUNT, +} from '../v2/utils/avatars'; interface AvatarOption { id: string; @@ -33,11 +35,11 @@ export const getAvatarColor = (avatarId: string | undefined | null): string => { // week: no per-pick API cost, same face forever, and SVG that scales to any // surface. // -// Storage scheme: profilePicture = 'bigsmile:'. The seed — not image -// bytes or a URL — is the identity, so every renderer regenerates the exact -// face locally and nothing 404s, expires, or needs hosting. getAvatarSrc -// resolves the scheme, which means every existing consumer (v1 Avatar src, -// V2Avatar) renders picked faces with zero per-component changes. +// Character storage scheme: profilePicture = 'bigsmile:' or +// 'bottts:'. The seed — not image bytes or a URL — is the identity, so +// every renderer regenerates the exact face locally and nothing 404s, expires, +// or needs hosting. Paper looks use the separate persisted +// 'paper:-v' scheme, where n is the exact 0-based look index. export const FACE_PRESET_PREFIX = 'bigsmile:'; // Agents use the same scheme with their own species (Sam: owners can edit an // agent's avatar too). One resolver, two prefixes — the prefix IS the kind. @@ -92,6 +94,10 @@ const isLikelyImageUrl = (value: string | undefined | null): boolean => { export const getAvatarSrc = (avatarId: string | undefined | null): string | null | undefined => { if (!avatarId) return null; if (avatarOptions.some((option) => option.id === avatarId)) return null; + const paperPreset = /^paper:(.+)-v(0|[1-9]\d*)$/.exec(avatarId); + if (paperPreset) { + return paperAvatarFor(paperPreset[1], Number(paperPreset[2])); + } // Picked character preset (face or robot): regenerate locally from the // stored seed. Data-URI out, so every -based consumer renders it with // no network involved. diff --git a/frontend/src/v2/__tests__/V2Avatar.test.tsx b/frontend/src/v2/__tests__/V2Avatar.test.tsx index cbcfb3688..6a758b046 100644 --- a/frontend/src/v2/__tests__/V2Avatar.test.tsx +++ b/frontend/src/v2/__tests__/V2Avatar.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; import V2Avatar from '../components/V2Avatar'; +import { paperAvatarFor } from '../utils/avatars'; const svgFromDataUri = (src: string | null): string => decodeURIComponent( String(src).replace(/^data:image\/svg\+xml;utf8,/, ''), @@ -73,6 +74,16 @@ describe('V2Avatar', () => { ); }); + test('renders the exact stored Paper look from its 0-based profile marker', () => { + const marker = 'paper:paper-user-v17'; + render(); + + expect(screen.getByRole('img', { name: 'Ada' })).toHaveAttribute( + 'src', + paperAvatarFor('paper-user', 17), + ); + }); + test('falls back to Paper when a human photo fails to load', () => { render( , diff --git a/frontend/src/v2/__tests__/V2AvatarCropDialog.test.tsx b/frontend/src/v2/__tests__/V2AvatarCropDialog.test.tsx new file mode 100644 index 000000000..ef7d03f22 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AvatarCropDialog.test.tsx @@ -0,0 +1,240 @@ +import React, { useRef, useState } from 'react'; +import { + act, fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import '../../i18n'; +import V2AvatarCropDialog from '../components/V2AvatarCropDialog'; + +const selectedFile = new File(['original'], 'original.jpg', { type: 'image/jpeg' }); + +describe('V2AvatarCropDialog', () => { + let image: HTMLImageElement; + let drawImage: jest.Mock; + let previousImageDescriptor: PropertyDescriptor | undefined; + let previousPointerEventDescriptor: PropertyDescriptor | undefined; + let previousCreateObjectURLDescriptor: PropertyDescriptor | undefined; + let previousRevokeObjectURLDescriptor: PropertyDescriptor | undefined; + let previousGetContextDescriptor: PropertyDescriptor | undefined; + let previousToDataURLDescriptor: PropertyDescriptor | undefined; + let previousToBlobDescriptor: PropertyDescriptor | undefined; + let createElementSpy: jest.SpyInstance | null = null; + + beforeAll(() => { + previousImageDescriptor = Object.getOwnPropertyDescriptor(window, 'Image'); + previousPointerEventDescriptor = Object.getOwnPropertyDescriptor(window, 'PointerEvent'); + previousCreateObjectURLDescriptor = Object.getOwnPropertyDescriptor(URL, 'createObjectURL'); + previousRevokeObjectURLDescriptor = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL'); + previousGetContextDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'getContext'); + previousToDataURLDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'toDataURL'); + previousToBlobDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'toBlob'); + }); + + beforeEach(() => { + image = document.createElement('img'); + Object.defineProperties(image, { + naturalWidth: { configurable: true, value: 1600 }, + naturalHeight: { configurable: true, value: 900 }, + }); + drawImage = jest.fn(); + const context = { + drawImage, + clearRect: jest.fn(), + imageSmoothingEnabled: false, + imageSmoothingQuality: 'low', + } as unknown as CanvasRenderingContext2D; + + Object.defineProperty(window, 'Image', { configurable: true, value: jest.fn(() => image) }); + Object.defineProperty(window, 'PointerEvent', { + configurable: true, + value: class TestPointerEvent extends MouseEvent { + pointerId: number; + + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId || 0; + } + }, + }); + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:avatar') }); + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() }); + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: jest.fn(() => context), + }); + Object.defineProperty(HTMLCanvasElement.prototype, 'toDataURL', { + configurable: true, + value: jest.fn(() => 'data:image/png;base64,preview'), + }); + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + configurable: true, + value: jest.fn((callback: BlobCallback) => callback(new Blob(['cropped'], { type: 'image/png' }))), + }); + }); + + afterAll(() => { + const restore = (target: object, key: PropertyKey, descriptor?: PropertyDescriptor) => { + if (descriptor) Object.defineProperty(target, key, descriptor); + else delete (target as Record)[key]; + }; + restore(window, 'Image', previousImageDescriptor); + restore(window, 'PointerEvent', previousPointerEventDescriptor); + restore(URL, 'createObjectURL', previousCreateObjectURLDescriptor); + restore(URL, 'revokeObjectURL', previousRevokeObjectURLDescriptor); + restore(HTMLCanvasElement.prototype, 'getContext', previousGetContextDescriptor); + restore(HTMLCanvasElement.prototype, 'toDataURL', previousToDataURLDescriptor); + restore(HTMLCanvasElement.prototype, 'toBlob', previousToBlobDescriptor); + }); + + afterEach(() => { + createElementSpy?.mockRestore(); + createElementSpy = null; + }); + + test('keeps a square preview and the zoomed crop can be saved as PNG', async () => { + const onSave = jest.fn(); + const createdCanvases: HTMLCanvasElement[] = []; + const originalCreateElement = document.createElement.bind(document); + createElementSpy = jest.spyOn(document, 'createElement').mockImplementation(((tagName: string, options?: ElementCreationOptions) => { + const element = originalCreateElement(tagName, options); + if (tagName === 'canvas') createdCanvases.push(element as HTMLCanvasElement); + return element; + }) as typeof document.createElement); + render( + , + ); + + expect(screen.getByRole('dialog', { name: 'Upload photo' })).toBeInTheDocument(); + act(() => { image.onload?.call(image, new Event('load')); }); + expect(await screen.findByLabelText('Avatar crop preview')).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Zoom' })).toHaveAttribute('min', '1'); + expect(screen.getByRole('slider', { name: 'Zoom' })).toHaveValue('1'); + expect(document.querySelector('.v2-settings__avatar-crop-frame')).toBeInTheDocument(); + expect(document.querySelectorAll('.v2-settings__avatar-preview-image')).toHaveLength(3); + const initialImage = drawImage.mock.calls[0]; + expect(initialImage[0]).toBe(image); + expect(initialImage[1]).toBeCloseTo((392 - 150.8) / 2 - (350 * 150.8) / 900); + expect(initialImage[2]).toBeCloseTo((260 - 150.8) / 2); + expect(initialImage[3]).toBeCloseTo((1600 * 150.8) / 900); + expect(initialImage[4]).toBeCloseTo(150.8); + expect(drawImage.mock.calls[1]).toEqual([image, 350, 0, 900, 900, 0, 0, 64, 64]); + + fireEvent.change(screen.getByLabelText('Zoom'), { target: { value: '2' } }); + expect(drawImage).toHaveBeenLastCalledWith(image, 575, 225, 450, 450, 0, 0, 64, 64); + fireEvent.click(screen.getByRole('button', { name: 'Save photo' })); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'image/png' }))); + expect((document.querySelector('canvas') as HTMLCanvasElement).width).toBe(392); + expect(createdCanvases.map((canvas) => canvas.width)).toContain(512); + }); + + test('Escape cancels and restores focus to the upload control', () => { + const onCancel = jest.fn(); + const Wrapper = () => { + const [open, setOpen] = useState(false); + const uploadButtonRef = useRef(null); + return ( + <> + + {open && ( + { onCancel(); setOpen(false); }} + onSave={jest.fn()} + /> + )} + + ); + }; + render(); + const uploadButton = screen.getByRole('button', { name: 'Upload photo' }); + uploadButton.focus(); + fireEvent.click(uploadButton); + + const reachedDocument = jest.fn(); + document.addEventListener('keydown', reachedDocument); + fireEvent.keyDown(screen.getByRole('dialog', { name: 'Upload photo' }), { key: 'Escape' }); + document.removeEventListener('keydown', reachedDocument); + + // Escape must not reach App.tsx's setupFocusManagement, which would blur the restored focus. + expect(reachedDocument).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(uploadButton).toHaveFocus(); + expect(document.body.style.overflow).toBe(''); + }); + + test('keeps Save photo focusable and ignores activation while saving', async () => { + const onSave = jest.fn(); + const onCancel = jest.fn(); + const { rerender } = render( + , + ); + act(() => { image.onload?.call(image, new Event('load')); }); + const saveButton = await screen.findByRole('button', { name: 'Save photo' }); + saveButton.focus(); + + rerender( + , + ); + const savingButton = screen.getByRole('button', { name: 'Saving…' }); + expect(savingButton).toHaveAttribute('aria-disabled', 'true'); + expect(savingButton).not.toBeDisabled(); + expect(savingButton).toHaveFocus(); + + fireEvent.click(savingButton); + expect(onSave).not.toHaveBeenCalled(); + expect(savingButton).toHaveFocus(); + }); + + test('dragging the source image changes the square crop', () => { + render( + , + ); + act(() => { image.onload?.call(image, new Event('load')); }); + const canvas = screen.getByLabelText('Avatar crop preview') as HTMLCanvasElement; + Object.defineProperty(canvas, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, y: 0, top: 0, left: 0, right: 392, bottom: 260, width: 392, height: 260, + toJSON: () => ({}), + }), + }); + + fireEvent.pointerDown(canvas, { pointerId: 1, clientX: 180, clientY: 180 }); + fireEvent.pointerMove(canvas, { pointerId: 1, clientX: 200, clientY: 180 }); + fireEvent.pointerUp(canvas, { pointerId: 1, clientX: 200, clientY: 180 }); + + const crop = drawImage.mock.calls[drawImage.mock.calls.length - 1]; + expect(crop[0]).toBe(image); + expect(crop[1]).toBeCloseTo(350 - (20 * 900) / 150.8); + expect(crop[2]).toBeCloseTo(0); + expect(crop[3]).toBeCloseTo(900); + expect(crop[4]).toBeCloseTo(900); + }); +}); diff --git a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx index fcd9da21a..c8978738b 100644 --- a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import '../../i18n'; import axios from '../../utils/axiosConfig'; import { MemoryRouter } from 'react-router-dom'; @@ -29,6 +29,22 @@ jest.mock('../components/V2Avatar', () => { return MockV2Avatar; }); +jest.mock('../components/V2AvatarCropDialog', () => { + const React = require('react'); + const MockV2AvatarCropDialog = ({ onSave, error }: { onSave: (image: Blob) => void; error: string | null }) => React.createElement( + 'section', + { role: 'dialog' }, + error && React.createElement('p', { role: 'alert' }, error), + React.createElement( + 'button', + { type: 'button', onClick: () => onSave(new Blob(['cropped'], { type: 'image/png' })) }, + 'Apply crop', + ), + ); + MockV2AvatarCropDialog.displayName = 'MockV2AvatarCropDialog'; + return { __esModule: true, default: MockV2AvatarCropDialog }; +}); + jest.mock('../components/V2BillingPanel', () => { const MockV2BillingPanel = ({ showHeading }: { showHeading?: boolean }) =>
Plan controls
; MockV2BillingPanel.displayName = 'MockV2BillingPanel'; @@ -51,8 +67,8 @@ const auth = { const TOKEN_CREATED_AT = '2026-09-04T12:00:00.000Z'; // The page links to the admin routes, so it renders under a router. -const renderSettings = () => render( - +const renderSettings = (currentUser = auth.currentUser) => render( +
, ); @@ -86,6 +102,9 @@ describe('V2SettingsPage', () => { expect(screen.getByRole('img', { name: 'lily avatar' })).toHaveAttribute('src', '/uploads/lily.png'); expect(screen.getByRole('img', { name: 'lily avatar' })).toHaveAttribute('data-kind', 'human'); expect(screen.getByText('Connected app controls')).toHaveAttribute('data-variant', 'settings'); + expect(screen.getByRole('button', { name: 'Regenerate' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Upload photo' })).toBeInTheDocument(); + expect(screen.getByText('Regenerate for another, or upload a photo.')).toBeInTheDocument(); expect(screen.getByRole('radio', { name: 'English' })).toBeInTheDocument(); expect(screen.getByRole('radio', { name: '中文' })).toBeInTheDocument(); }); @@ -149,6 +168,159 @@ describe('V2SettingsPage', () => { expect(screen.queryByText(/re-verification; ask us for now/i)).not.toBeInTheDocument(); }); + test('Regenerate saves the next Paper seed instead of repeating the current look', async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + auth.updateProfile.mockResolvedValue({ ...auth.currentUser, profilePicture: 'paper:u1-v1' }); + renderSettings({ ...auth.currentUser, profilePicture: 'paper:u1-v0' }); + + fireEvent.click(screen.getByRole('button', { name: 'Regenerate' })); + + await waitFor(() => expect(auth.updateProfile).toHaveBeenCalledWith({ + profilePicture: 'paper:u1-v1', + })); + expect(await screen.findByRole('status')).toHaveTextContent('Avatar changed.'); + }); + + test('keeps Regenerate focused and ignores repeated activation while the profile write is pending', async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + let finishUpdate: (() => void) | undefined; + auth.updateProfile.mockReturnValue(new Promise((resolve) => { + finishUpdate = () => resolve({}); + })); + renderSettings({ ...auth.currentUser, profilePicture: 'paper:u1-v12' }); + + const regenerateButton = screen.getByRole('button', { name: 'Regenerate' }); + regenerateButton.focus(); + fireEvent.click(regenerateButton); + + expect(regenerateButton).toHaveAttribute('aria-disabled', 'true'); + expect(regenerateButton).not.toBeDisabled(); + expect(regenerateButton).toHaveFocus(); + fireEvent.keyDown(regenerateButton, { key: 'Enter' }); + fireEvent.click(regenerateButton); + expect(auth.updateProfile).toHaveBeenCalledTimes(1); + + await act(async () => { finishUpdate?.(); }); + expect(await screen.findByRole('status')).toHaveTextContent('Avatar changed.'); + expect(regenerateButton).toHaveFocus(); + }); + + test('keeps the no-face explanation for Paper and shortens it for a picked face or photo', () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + const { rerender } = renderSettings({ ...auth.currentUser, profilePicture: 'default' }); + + expect(screen.getByText('No face and no skin tone until you choose one. Regenerate for another, or upload a photo.')) + .toBeInTheDocument(); + + rerender( + +
+
, + ); + expect(screen.getByText('No face and no skin tone until you choose one. Regenerate for another, or upload a photo.')) + .toBeInTheDocument(); + + rerender( + +
+
, + ); + expect(screen.getByText('Regenerate for another, or upload a photo.')).toBeInTheDocument(); + expect(screen.queryByText('No face and no skin tone until you choose one. Regenerate for another, or upload a photo.')) + .not.toBeInTheDocument(); + }); + + test('Regenerate reports a profile save error without losing the current avatar', async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + auth.updateProfile.mockRejectedValue(new Error('profile update failed')); + renderSettings({ ...auth.currentUser, profilePicture: 'paper:u1-v12' }); + + fireEvent.click(screen.getByRole('button', { name: 'Regenerate' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not change your avatar. Try again.'); + expect(screen.getByRole('img', { name: 'lily avatar' })).toHaveAttribute('src', 'paper:u1-v12'); + }); + + test('uploads a cropped image without a pod scope and saves the returned API path as the profile picture', async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + (axios.post as jest.Mock).mockResolvedValue({ data: { url: '/api/uploads/avatar-512.png' } }); + auth.updateProfile.mockResolvedValue({ ...auth.currentUser, profilePicture: '/api/uploads/avatar-512.png' }); + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Upload photo' })); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { + target: { files: [new File(['original'], 'original.jpg', { type: 'image/jpeg' })] }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Apply crop' })); + + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/uploads', + expect.any(FormData), + { headers: { 'Content-Type': 'multipart/form-data' } }, + )); + const upload = (axios.post as jest.Mock).mock.calls[0][1] as FormData; + const image = upload.get('image') as File; + expect(image.name).toBe('avatar.png'); + expect(image.type).toBe('image/png'); + expect(upload.get('podId')).toBeNull(); + await waitFor(() => expect(auth.updateProfile).toHaveBeenCalledWith({ + profilePicture: '/api/uploads/avatar-512.png', + })); + expect(await screen.findByRole('status')).toHaveTextContent('Photo saved.'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + test('keeps the crop step open with an error when the upload fails', async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + (axios.post as jest.Mock).mockRejectedValue(new Error('upload failed')); + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Upload photo' })); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { + target: { files: [new File(['original'], 'original.jpg', { type: 'image/jpeg' })] }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Apply crop' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not upload your photo. Try again.'); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Apply crop' })).toBeInTheDocument(); + expect(auth.updateProfile).not.toHaveBeenCalled(); + }); + + test('keeps the crop step open when the profile picture write fails after upload', async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + (axios.post as jest.Mock).mockResolvedValue({ data: { url: '/api/uploads/avatar-512.png' } }); + auth.updateProfile.mockRejectedValue(new Error('profile update failed')); + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Upload photo' })); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { + target: { files: [new File(['original'], 'original.jpg', { type: 'image/jpeg' })] }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Apply crop' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not save your avatar. Try again.'); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(auth.updateProfile).toHaveBeenCalledWith({ profilePicture: '/api/uploads/avatar-512.png' }); + }); + + test('rejects a selected non-image file before opening the crop dialog', async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Upload photo' })); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { + target: { files: [new File(['text'], 'notes.txt', { type: 'text/plain' })] }, + }); + + expect(await screen.findByRole('alert')).toHaveTextContent('Choose an image file.'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + test('an admin gets an Administration section with the users and usage-analytics links; a member does not', () => { // The routes survived the Settings consolidation (#1544); the way in did // not, so both admin pages were reachable only by URL. diff --git a/frontend/src/v2/__tests__/avatarCrop.test.ts b/frontend/src/v2/__tests__/avatarCrop.test.ts new file mode 100644 index 000000000..bf3af39e0 --- /dev/null +++ b/frontend/src/v2/__tests__/avatarCrop.test.ts @@ -0,0 +1,31 @@ +import { + imageDrawRectForCrop, squareCropRectFor, +} from '../utils/avatarCrop'; + +describe('squareCropRectFor', () => { + test('starts centered and makes a square from a landscape image', () => { + expect(squareCropRectFor(1600, 900, 1)).toEqual({ x: 350, y: 0, side: 900 }); + }); + + test('zooms around the selected center and clamps the crop inside the image', () => { + expect(squareCropRectFor(1200, 800, 2, { x: 0, y: 800 })).toEqual({ + x: 0, y: 400, side: 400, + }); + }); + + test('bounds zoom to the supported range and rejects invalid image dimensions', () => { + expect(squareCropRectFor(640, 480, 20)?.side).toBe(480 / 3); + expect(squareCropRectFor(0, 480, 1)).toBeNull(); + expect(squareCropRectFor(640, Number.NaN, 1)).toBeNull(); + }); + + test('positions the full source image behind the fixed crop frame', () => { + const crop = squareCropRectFor(1600, 900, 1); + expect(crop).not.toBeNull(); + const imageRect = imageDrawRectForCrop(1600, 900, crop as NonNullable, 392, 260, 150.8); + expect(imageRect?.x).toBeCloseTo((392 - 150.8) / 2 - (350 * 150.8) / 900); + expect(imageRect?.y).toBeCloseTo((260 - 150.8) / 2); + expect(imageRect?.width).toBeCloseTo((1600 * 150.8) / 900); + expect(imageRect?.height).toBeCloseTo(150.8); + }); +}); diff --git a/frontend/src/v2/__tests__/avatarPresets.test.ts b/frontend/src/v2/__tests__/avatarPresets.test.ts new file mode 100644 index 000000000..6032a7b2c --- /dev/null +++ b/frontend/src/v2/__tests__/avatarPresets.test.ts @@ -0,0 +1,52 @@ +import { + defaultPaperAvatarPresetFor, + nextPaperAvatarPreset, + PAPER_AVATAR_LOOK_COUNT, + paperAvatarPresetFor, +} from '../utils/avatarPresets'; + +describe('Paper avatar profile presets', () => { + test('regenerate advances past the unpicked default and visits all 24 looks once', () => { + const userId = 'paper-user-234'; + const initial = defaultPaperAvatarPresetFor(userId); + let current = nextPaperAvatarPreset(userId, 'default'); + const selections = [current]; + + expect(current).not.toBe(initial); + for (let i = 1; i < PAPER_AVATAR_LOOK_COUNT; i += 1) { + current = nextPaperAvatarPreset(userId, current); + selections.push(current); + } + + expect(new Set(selections).size).toBe(PAPER_AVATAR_LOOK_COUNT); + expect(nextPaperAvatarPreset(userId, current)).toBe(selections[0]); + }); + + test('regenerate advances from a stored Paper cell and wraps after the last look', () => { + expect(nextPaperAvatarPreset('paper-user-234', paperAvatarPresetFor('paper-user-234', 23))) + .toBe(paperAvatarPresetFor('paper-user-234', 0)); + }); + + test('photos and older Cut picks are replaced by a Paper seed', () => { + expect(nextPaperAvatarPreset('paper-user-234', '/api/uploads/avatar.png')) + .toMatch(/^paper:paper-user-234-v(?:[0-9]|1[0-9]|2[0-3])$/); + expect(nextPaperAvatarPreset('paper-user-234', 'bigsmile:paper-user-234-v7')) + .toMatch(/^paper:paper-user-234-v(?:[0-9]|1[0-9]|2[0-3])$/); + }); + + test('preset helper rejects indices outside the exact 0-based range', () => { + expect(() => paperAvatarPresetFor('paper-user-234', -1)).toThrow(); + expect(() => paperAvatarPresetFor('paper-user-234', 24)).toThrow(); + expect(() => paperAvatarPresetFor('paper-user-234', 1.5)).toThrow(); + }); + + test.each(['v01', 'v0x1', 'v24'])('treats a non-canonical stored marker %s as the current default', (suffix) => { + const userId = 'paper-user-234'; + const initial = defaultPaperAvatarPresetFor(userId); + const defaultIndex = Number(initial.slice(initial.lastIndexOf('-v') + 2)); + const nextIndex = (defaultIndex + 1) % PAPER_AVATAR_LOOK_COUNT; + + expect(nextPaperAvatarPreset(userId, `paper:${userId}-${suffix}`)) + .toBe(paperAvatarPresetFor(userId, nextIndex)); + }); +}); diff --git a/frontend/src/v2/__tests__/paperAvatarResolver.test.ts b/frontend/src/v2/__tests__/paperAvatarResolver.test.ts new file mode 100644 index 000000000..a1b281754 --- /dev/null +++ b/frontend/src/v2/__tests__/paperAvatarResolver.test.ts @@ -0,0 +1,46 @@ +import { getAvatarSrc } from '../../utils/avatarUtils'; +import { PAPER_AVATAR_LOOK_COUNT, paperAvatarFor } from '../utils/avatars'; +import { + defaultPaperAvatarPresetFor, + paperAvatarPresetFor, +} from '../utils/avatarPresets'; + +describe('stored Paper avatar resolver', () => { + test('resolves every exact 0-based marker to its corresponding Paper look', () => { + const userId = 'paper-user-234'; + const resolved = Array.from({ length: PAPER_AVATAR_LOOK_COUNT }, (_, lookIndex) => { + const marker = paperAvatarPresetFor(userId, lookIndex); + return getAvatarSrc(marker); + }); + + expect(resolved.every((src) => typeof src === 'string' && src.startsWith('data:image/svg+xml;utf8,'))) + .toBe(true); + expect(resolved).toEqual( + Array.from({ length: PAPER_AVATAR_LOOK_COUNT }, (_, lookIndex) => paperAvatarFor(userId, lookIndex)), + ); + expect(new Set(resolved).size).toBe(PAPER_AVATAR_LOOK_COUNT); + }); + + test('the saved default marker renders the same look as the unpicked fallback', () => { + const userId = 'paper-user-234'; + expect(getAvatarSrc(defaultPaperAvatarPresetFor(userId))).toBe(paperAvatarFor(userId)); + }); + + test.each([-1, PAPER_AVATAR_LOOK_COUNT, 1.5])( + 'rejects Paper look indices outside the exact 0-based range: %s', + (lookIndex) => { + expect(paperAvatarFor('paper-user-234', lookIndex)).toBeNull(); + }, + ); + + test.each([ + 'paper:user-v24', + 'paper:user-v01', + 'paper:user-v0x1', + 'paper:user-v-1', + 'paper:user-v', + 'paper:-v0', + ])('rejects malformed or out-of-range marker %s', (marker) => { + expect(getAvatarSrc(marker)).toBeNull(); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 2d3a50644..b367f3e8c 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -172,6 +172,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { const app = read('../../App.tsx'); const appStyles = read('../../App.css'); const settingsPage = read('../components/V2SettingsPage.tsx'); + const avatarCropDialog = read('../components/V2AvatarCropDialog.tsx'); const avatar = read('../components/V2Avatar.tsx'); const billingPanel = read('../components/V2BillingPanel.tsx'); const appsManagement = read('../../components/AppsManagement.tsx'); @@ -891,7 +892,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(settingsPage).toContain(''); expect(settingsPage).toContain(''); expect(settingsPage).toContain(''); - expect(ruleBody(v2, '.v2-settings__avatar')).toContain('width: 40px'); + expect(ruleBody(v2, '.v2-settings__avatar')).toContain('width: 64px'); expect(ruleBody(v2, '.v2-settings__avatar')).toContain('border-radius: var(--v2-radius-sm)'); expect(ruleBody(v2, '.v2-settings__avatar img')).toContain('border-radius: var(--v2-radius-sm)'); expect(avatar).toContain("borderRadius: 'inherit'"); @@ -931,6 +932,38 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(accountForm).toContain('box-shadow: none'); }); + test('Settings avatar controls keep the account face square and the crop step usable on phones', () => { + const avatar = ruleBody(v2, '.v2-settings__avatar'); + expect(avatar).toContain('width: 64px'); + expect(avatar).toContain('height: 64px'); + expect(avatar).toContain('border-radius: var(--v2-radius-sm)'); + expect(ruleBody(v2, '.v2-settings__avatar-overlay')).toContain('background: rgba(16, 24, 40, 0.55)'); + expect(avatarCropDialog).toContain('className="v2-root v2-modal__overlay v2-settings__avatar-overlay"'); + expect(avatarCropDialog).toContain('className="v2-modal v2-settings__avatar-dialog"'); + expect(ruleBody(v2, '.v2-settings__avatar-crop-stage')).toContain('aspect-ratio: 392 / 260'); + expect(ruleBody(v2, '.v2-settings__avatar-crop-frame')).toContain('border: 1px solid var(--v2-surface)'); + expect(ruleBody(v2, '.v2-settings__avatar-crop-dim span')).toContain('background: rgba(16, 24, 40, 0.55)'); + expect(ruleBody(v2, '.v2-root .v2-settings__avatar-zoom input[type="range"]::-webkit-slider-thumb')).toContain('width: 16px'); + expect(ruleBody(v2, '.v2-root .v2-settings__avatar-dialog-actions button')).toContain('min-width: 112px'); + const ariaDisabledStyle = selectorRuleBody( + v2, + '.v2-root button.v2-settings__primary[aria-disabled="true"]', + ); + expect(ariaDisabledStyle).toContain('cursor: default'); + expect(ariaDisabledStyle).toContain('opacity: 0.55'); + expect(v2).toContain('.v2-root button.v2-settings__primary:hover:not(:disabled):not([aria-disabled="true"])'); + expect(v2).toContain('.v2-root button.v2-settings__secondary:hover:not(:disabled):not([aria-disabled="true"])'); + + const phone = blockContaining(v2, '@media (max-width: 680px)', '.v2-settings__avatar-actions'); + expect(ruleBody(phone, '.v2-settings__avatar-actions', { within: phone })).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))'); + expect(ruleBody(phone, '.v2-root .v2-settings__avatar-actions button.v2-settings__secondary', { within: phone })).toContain('min-height: 44px'); + expect(ruleBody(phone, '.v2-settings__avatar-footer', { within: phone })).toContain('flex-direction: column'); + expect(ruleBody(phone, '.v2-root .v2-settings__avatar-dialog-actions button', { within: phone })).toContain('min-height: 44px'); + + const modalPhone = blockContaining(v2, '@media (max-width: 480px)', '.v2-modal__overlay'); + expect(ruleBody(modalPhone, '.v2-modal__overlay', { within: modalPhone })).toContain('padding: 12px'); + }); + test('App.css keeps every V1 element selector out of the v2 canvas', () => { // This catches both a new `select { ... }` rule and a comma-list escape // such as `textarea, form { ... }`, neither of which a named allowlist diff --git a/frontend/src/v2/components/V2AvatarCropDialog.tsx b/frontend/src/v2/components/V2AvatarCropDialog.tsx new file mode 100644 index 000000000..e131d1324 --- /dev/null +++ b/frontend/src/v2/components/V2AvatarCropDialog.tsx @@ -0,0 +1,341 @@ +import React, { + useEffect, useRef, useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { + createAvatarCropBlob, CropCenter, imageDrawRectForCrop, squareCropRectFor, +} from '../utils/avatarCrop'; + +interface V2AvatarCropDialogProps { + file: File; + saving: boolean; + error: string | null; + returnFocusElement?: HTMLElement | null; + onCancel: () => void; + onSave: (image: Blob) => void; +} + +const STAGE_WIDTH = 392; +const STAGE_HEIGHT = 260; +const FRAME_SIZE = STAGE_HEIGHT * 0.58; +const PREVIEW_SIZE = 64; +const OUTPUT_SIZE = 512; + +const centerOf = (image: HTMLImageElement): CropCenter => ({ + x: image.naturalWidth / 2, + y: image.naturalHeight / 2, +}); + +const V2AvatarCropDialog: React.FC = ({ + file, saving, error, onCancel, onSave, + returnFocusElement, +}) => { + const { t } = useTranslation(); + const dialogRef = useRef(null); + const cancelButtonRef = useRef(null); + const canvasRef = useRef(null); + const previewCanvasRef = useRef(null); + const dragRef = useRef<{ pointerId: number; x: number; y: number } | null>(null); + const [image, setImage] = useState(null); + const [center, setCenter] = useState(null); + const [zoom, setZoom] = useState(1); + const [previewSrc, setPreviewSrc] = useState(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + let active = true; + setImage(null); + setCenter(null); + setPreviewSrc(null); + setLoadError(null); + const objectUrl = URL.createObjectURL(file); + const nextImage = new Image(); + nextImage.onload = () => { + if (!active) return; + setImage(nextImage); + setCenter(centerOf(nextImage)); + setZoom(1); + setLoadError(null); + }; + nextImage.onerror = () => { + if (active) setLoadError(t('settings.avatar.imageCouldNotLoad')); + }; + nextImage.src = objectUrl; + + return () => { + active = false; + nextImage.onload = null; + nextImage.onerror = null; + URL.revokeObjectURL(objectUrl); + }; + }, [file, t]); + + useEffect(() => { + if (!image || !center) return; + const canvas = canvasRef.current; + const context = canvas?.getContext('2d'); + const crop = squareCropRectFor(image.naturalWidth, image.naturalHeight, zoom, center); + const imageRect = crop && imageDrawRectForCrop( + image.naturalWidth, + image.naturalHeight, + crop, + STAGE_WIDTH, + STAGE_HEIGHT, + FRAME_SIZE, + ); + if (!canvas || !context || !crop || !imageRect) return; + + canvas.width = STAGE_WIDTH; + canvas.height = STAGE_HEIGHT; + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = 'high'; + context.clearRect(0, 0, STAGE_WIDTH, STAGE_HEIGHT); + context.drawImage(image, imageRect.x, imageRect.y, imageRect.width, imageRect.height); + + const previewCanvas = previewCanvasRef.current || document.createElement('canvas'); + previewCanvasRef.current = previewCanvas; + previewCanvas.width = PREVIEW_SIZE; + previewCanvas.height = PREVIEW_SIZE; + const previewContext = previewCanvas.getContext('2d'); + if (!previewContext) return; + previewContext.imageSmoothingEnabled = true; + previewContext.imageSmoothingQuality = 'high'; + previewContext.drawImage( + image, + crop.x, + crop.y, + crop.side, + crop.side, + 0, + 0, + PREVIEW_SIZE, + PREVIEW_SIZE, + ); + try { + setPreviewSrc(previewCanvas.toDataURL('image/png')); + } catch { + setPreviewSrc(null); + } + }, [center, image, zoom]); + + useEffect(() => { + const previousFocus = returnFocusElement + || (document.activeElement instanceof HTMLElement ? document.activeElement : null); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + cancelButtonRef.current?.focus(); + return () => { + document.body.style.overflow = previousOverflow; + previousFocus?.focus(); + }; + }, [returnFocusElement]); + + const handleDialogKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + if (!saving) onCancel(); + event.preventDefault(); + // App.tsx's setupFocusManagement blurs the active element on any Escape that reaches document. + event.stopPropagation(); + return; + } + if (event.key !== 'Tab') return; + + const focusable = dialogRef.current?.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (!focusable?.length) { + event.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + const beginDrag = (event: React.PointerEvent) => { + if (!image || !center || saving) return; + event.preventDefault(); + dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + if (typeof event.currentTarget.setPointerCapture === 'function') { + event.currentTarget.setPointerCapture(event.pointerId); + } + }; + + const moveCrop = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId || !image) return; + const bounds = event.currentTarget.getBoundingClientRect(); + const crop = squareCropRectFor(image.naturalWidth, image.naturalHeight, zoom, center || centerOf(image)); + if (!crop || bounds.width <= 0 || bounds.height <= 0) return; + + const frameScreenSide = bounds.width * FRAME_SIZE / STAGE_WIDTH; + if (frameScreenSide <= 0) return; + const dx = (event.clientX - drag.x) * crop.side / frameScreenSide; + const dy = (event.clientY - drag.y) * crop.side / frameScreenSide; + const currentCenter = { x: crop.x + crop.side / 2, y: crop.y + crop.side / 2 }; + const nextCrop = squareCropRectFor(image.naturalWidth, image.naturalHeight, zoom, { + x: currentCenter.x - dx, + y: currentCenter.y - dy, + }); + if (nextCrop) setCenter({ x: nextCrop.x + nextCrop.side / 2, y: nextCrop.y + nextCrop.side / 2 }); + dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + }; + + const endDrag = (event: React.PointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + if (typeof event.currentTarget.releasePointerCapture === 'function' + && event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + const moveCropWithKeyboard = (event: React.KeyboardEvent) => { + if (!image || !center || saving) return; + const directions: Record = { + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, + }; + const direction = directions[event.key]; + if (!direction) return; + event.preventDefault(); + const crop = squareCropRectFor(image.naturalWidth, image.naturalHeight, zoom, center); + if (!crop) return; + const step = Math.max(1, crop.side * (event.shiftKey ? 0.05 : 0.01)); + const nextCrop = squareCropRectFor(image.naturalWidth, image.naturalHeight, zoom, { + x: center.x + direction.x * step, + y: center.y + direction.y * step, + }); + if (nextCrop) setCenter({ x: nextCrop.x + nextCrop.side / 2, y: nextCrop.y + nextCrop.side / 2 }); + }; + + const save = async () => { + if (!image || !center || saving) return; + const crop = squareCropRectFor(image.naturalWidth, image.naturalHeight, zoom, center); + if (!crop) return; + try { + const blob = await createAvatarCropBlob(image, crop, OUTPUT_SIZE); + onSave(blob); + } catch { + setLoadError(t('settings.avatar.cropCouldNotSave')); + } + }; + + const minimumZoom = 1; + const zoomRange = Math.max(0.00001, 3 - minimumZoom); + const zoomProgress = `${Math.max(0, Math.min(100, ((zoom - minimumZoom) / zoomRange) * 100))}%`; + + return createPortal(( +
{ + if (event.target === event.currentTarget && !saving) onCancel(); + }} + > +
event.stopPropagation()} + > +
+

+ {t('settings.avatar.uploadTitle')} +

+ +
+
+
+ + + + {!image && !loadError && ( +

{t('settings.avatar.loadingImage')}

+ )} + +

+ {t('settings.avatar.cropInstructions')} +

+
+
+ {[64, 32, 22].map((size) => ( +
+ {t('settings.avatar.previewAt', { size })} + {previewSrc && } +
+ ))} +
+
+ + +
+
+ {(error || loadError) && ( +

{error || loadError}

+ )} +
+
+
+ ), document.body); +}; + +export default V2AvatarCropDialog; diff --git a/frontend/src/v2/components/V2SettingsPage.tsx b/frontend/src/v2/components/V2SettingsPage.tsx index 05b234464..92bb79ca9 100644 --- a/frontend/src/v2/components/V2SettingsPage.tsx +++ b/frontend/src/v2/components/V2SettingsPage.tsx @@ -1,12 +1,15 @@ import { Link } from 'react-router-dom'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; +import { getAvatarSrc } from '../../utils/avatarUtils'; import { useAuth } from '../../context/AuthContext'; import AppsManagement from '../../components/AppsManagement'; import V2BillingPanel from './V2BillingPanel'; import V2DevicesPanel from './V2DevicesPanel'; import V2Avatar from './V2Avatar'; +import V2AvatarCropDialog from './V2AvatarCropDialog'; +import { nextPaperAvatarPreset } from '../utils/avatarPresets'; type TokenStatus = { hasToken?: boolean; @@ -66,14 +69,24 @@ const SettingsSection: React.FC = ({ id, title, children } const V2AccountSection: React.FC = () => { const { currentUser, updateProfile } = useAuth(); + const { t } = useTranslation(); const accountName = String(currentUser?.displayName || currentUser?.username || 'Your account'); const accountUsername = String(currentUser?.username || ''); const accountEmail = String(currentUser?.email || ''); + const profilePicture = String(currentUser?.profilePicture || 'default'); + const hasFaceOrPhoto = profilePicture.startsWith('bigsmile:') + || (!profilePicture.startsWith('paper:') && Boolean(getAvatarSrc(profilePicture))); const [name, setName] = useState(accountName); const [email, setEmail] = useState(accountEmail); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); + const [avatarFile, setAvatarFile] = useState(null); + const [regeneratingAvatar, setRegeneratingAvatar] = useState(false); + const [savingPhoto, setSavingPhoto] = useState(false); + const [photoError, setPhotoError] = useState(null); + const fileInputRef = useRef(null); + const uploadPhotoButtonRef = useRef(null); useEffect(() => { setName(accountName); @@ -103,6 +116,65 @@ const V2AccountSection: React.FC = () => { } }; + const choosePhoto = () => fileInputRef.current?.click(); + + const regenerateAvatar = async () => { + setRegeneratingAvatar(true); + setPhotoError(null); + setError(null); + setNotice(null); + try { + const userId = String(currentUser?._id || currentUser?.id || ''); + const profilePicture = nextPaperAvatarPreset(userId, currentUser?.profilePicture); + await updateProfile({ profilePicture }); + setNotice(t('settings.avatar.regenerated')); + } catch { + setPhotoError(t('settings.avatar.regenerateFailed')); + } finally { + setRegeneratingAvatar(false); + } + }; + + const selectPhoto = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + if (!file.type.startsWith('image/')) { + setPhotoError(t('settings.avatar.imageTypeRequired')); + setError(null); + return; + } + setPhotoError(null); + setError(null); + setNotice(null); + setAvatarFile(file); + }; + + const saveCroppedPhoto = async (image: Blob) => { + setSavingPhoto(true); + setPhotoError(null); + setError(null); + setNotice(null); + let uploaded = false; + try { + const formData = new FormData(); + formData.append('image', image, 'avatar.png'); + const upload = await axios.post<{ url?: string }>('/api/uploads', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + const profilePicture = upload.data?.url; + if (!profilePicture?.startsWith('/api/uploads/')) throw new Error('Upload path was not returned'); + uploaded = true; + await updateProfile({ profilePicture }); + setNotice(t('settings.avatar.photoSaved')); + setAvatarFile(null); + } catch { + setPhotoError(t(uploaded ? 'settings.avatar.profileSaveFailed' : 'settings.avatar.uploadFailed')); + } finally { + setSavingPhoto(false); + } + }; + return (
@@ -120,6 +192,40 @@ const V2AccountSection: React.FC = () => {
{currentUser?.role === 'admin' ? 'administrator' : 'member'}
+ +
+ + +
+

+ {t(hasFaceOrPhoto ? 'settings.avatar.descriptionAfterChoice' : 'settings.avatar.description')} +

+ {photoError && !avatarFile && ( +

{photoError}

+ )}