diff --git a/backend/deploy/nginx-visual.loki-code.dev.conf b/backend/deploy/nginx-visual.loki-code.dev.conf index c150fa3..66341f5 100644 --- a/backend/deploy/nginx-visual.loki-code.dev.conf +++ b/backend/deploy/nginx-visual.loki-code.dev.conf @@ -10,6 +10,10 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # The backend scopes the refresh cookie to Path=/auth, but the frontend calls it + # under /api/auth/* through this proxy — without this rewrite the browser never + # sends the cookie back on /api/auth/refresh and the session is lost on reload. + proxy_cookie_path /auth /api/auth; } location / { diff --git a/frontend/index.html b/frontend/index.html index dfd9350..1a92de6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,15 +1,34 @@ - + - + - LoVisual · визуалы, в которые влюбляешься + LoVisual, visuals you'll fall in love with + + + + + + + + + + + +
diff --git a/frontend/src/app/i18n.ts b/frontend/src/app/i18n.ts index d67260b..b289c95 100644 --- a/frontend/src/app/i18n.ts +++ b/frontend/src/app/i18n.ts @@ -60,7 +60,9 @@ export function initI18n(lng?: 'ru' | 'en'): i18n { profile: profileEn, }, }, - fallbackLng: 'ru', + // Browser language decides the default: only an actual `ru` locale gets Russian, + // every other system language falls back to English. + fallbackLng: 'en', supportedLngs: ['ru', 'en'], ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'], defaultNS: 'common', diff --git a/frontend/src/features/account/components/AvatarCropper.tsx b/frontend/src/features/account/components/AvatarCropper.tsx new file mode 100644 index 0000000..a7ed12a --- /dev/null +++ b/frontend/src/features/account/components/AvatarCropper.tsx @@ -0,0 +1,124 @@ +import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '../../../shared/ui/Button' +import { clampPan, clampZoom, coverScale, MAX_ZOOM, MIN_ZOOM, sourceRect } from './cropMath' + +const VIEWPORT = 240 +const OUTPUT_SIZE = 512 + +type Props = { + file: File + onCancel: () => void + onConfirm: (blob: Blob) => void +} + +/** Drag-to-reposition, zoom-to-scale circular crop UI. Renders the selection onto an offscreen canvas on confirm. */ +export function AvatarCropper({ file, onCancel, onConfirm }: Props) { + const { t } = useTranslation('account') + const [url] = useState(() => URL.createObjectURL(file)) + const [natural, setNatural] = useState<{ w: number; h: number }>() + const [zoom, setZoom] = useState(MIN_ZOOM) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const [busy, setBusy] = useState(false) + const drag = useRef<{ x: number; y: number; offset: { x: number; y: number } } | null>(null) + + function onImgLoad(e: React.SyntheticEvent) { + setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight }) + } + + function onPointerDown(e: ReactPointerEvent) { + e.currentTarget.setPointerCapture(e.pointerId) + drag.current = { x: e.clientX, y: e.clientY, offset } + } + + function onPointerMove(e: ReactPointerEvent) { + if (!drag.current || !natural) return + const dx = e.clientX - drag.current.x + const dy = e.clientY - drag.current.y + const next = { x: drag.current.offset.x + dx, y: drag.current.offset.y + dy } + setOffset(clampPan(next, natural.w, natural.h, VIEWPORT, zoom)) + } + + function onPointerUp() { + drag.current = null + } + + function onZoomChange(next: number) { + const clamped = clampZoom(next) + setZoom(clamped) + if (natural) setOffset((prev) => clampPan(prev, natural.w, natural.h, VIEWPORT, clamped)) + } + + async function confirm() { + if (!natural) return + setBusy(true) + const img = new Image() + img.src = url + await img.decode() + const { sx, sy, sw, sh } = sourceRect(natural.w, natural.h, VIEWPORT, zoom, offset) + const canvas = document.createElement('canvas') + canvas.width = OUTPUT_SIZE + canvas.height = OUTPUT_SIZE + const ctx = canvas.getContext('2d') + if (!ctx) { + setBusy(false) + return + } + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE) + canvas.toBlob((blob) => { + setBusy(false) + if (blob) onConfirm(blob) + }, 'image/png') + } + + const scale = natural ? coverScale(natural.w, natural.h, VIEWPORT) * zoom : 1 + const drawnW = natural ? natural.w * scale : VIEWPORT + const drawnH = natural ? natural.h * scale : VIEWPORT + + return ( +
+
+ +
+ +

{t('avatar.reposition')}

+
+ + +
+
+ ) +} diff --git a/frontend/src/features/account/components/AvatarUpload.tsx b/frontend/src/features/account/components/AvatarUpload.tsx index 5094641..4d1b1fe 100644 --- a/frontend/src/features/account/components/AvatarUpload.tsx +++ b/frontend/src/features/account/components/AvatarUpload.tsx @@ -3,16 +3,21 @@ import { useId, useState, type ChangeEvent } from 'react' import { useTranslation } from 'react-i18next' import { Notice } from '../../../shared/ui/Notice' import { checkAvatarFile, uploadAvatar } from '../api' +import { AvatarCropper } from './AvatarCropper' -/** A label-based file picker (keyboard reachable) that uploads and refreshes the cached profile. */ +/** A label-based file picker (keyboard reachable) that opens a crop step before uploading and refreshing the cached profile. */ export function AvatarUpload() { const { t } = useTranslation('account') const id = useId() const client = useQueryClient() const [error, setError] = useState() + const [picked, setPicked] = useState() const mutation = useMutation({ mutationFn: uploadAvatar, - onSuccess: () => client.invalidateQueries({ queryKey: ['me'] }), + onSuccess: () => { + client.invalidateQueries({ queryKey: ['me'] }) + setPicked(undefined) + }, }) function onChange(e: ChangeEvent) { @@ -25,7 +30,21 @@ export function AvatarUpload() { return } setError(undefined) - mutation.mutate(file) + setPicked(file) + } + + function onConfirmCrop(blob: Blob) { + const cropped = new File([blob], 'avatar.png', { type: 'image/png' }) + mutation.mutate(cropped) + } + + if (picked) { + return ( +
+ setPicked(undefined)} onConfirm={onConfirmCrop} /> + {error ? {error} : null} +
+ ) } return ( diff --git a/frontend/src/features/account/components/cropMath.ts b/frontend/src/features/account/components/cropMath.ts new file mode 100644 index 0000000..f884590 --- /dev/null +++ b/frontend/src/features/account/components/cropMath.ts @@ -0,0 +1,60 @@ +/** Pure geometry for the circular avatar cropper. No DOM/canvas here so it's plain-arithmetic testable. */ + +export const MIN_ZOOM = 1 +export const MAX_ZOOM = 4 + +/** The zoom that makes the image exactly cover a `viewport`x`viewport` square at zoom=1 (our baseline). */ +export function coverScale(naturalWidth: number, naturalHeight: number, viewport: number): number { + return viewport / Math.min(naturalWidth, naturalHeight) +} + +/** + * Clamp a pan offset (in viewport pixels, center-relative) so the scaled image never + * shows empty space inside the `viewport`x`viewport` crop square. + */ +export function clampPan( + offset: { x: number; y: number }, + naturalWidth: number, + naturalHeight: number, + viewport: number, + zoom: number, +): { x: number; y: number } { + const scale = coverScale(naturalWidth, naturalHeight, viewport) * zoom + const scaledW = naturalWidth * scale + const scaledH = naturalHeight * scale + const maxX = Math.max(0, (scaledW - viewport) / 2) + const maxY = Math.max(0, (scaledH - viewport) / 2) + return { + x: Math.min(maxX, Math.max(-maxX, offset.x)), + y: Math.min(maxY, Math.max(-maxY, offset.y)), + } +} + +export function clampZoom(zoom: number): number { + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)) +} + +/** + * Given the image's natural size, the crop viewport size, the current pan offset (viewport px, + * center-relative) and zoom, compute the source rectangle (in natural image pixels) that should + * be drawn onto the output canvas. + */ +export function sourceRect( + naturalWidth: number, + naturalHeight: number, + viewport: number, + zoom: number, + offset: { x: number; y: number }, +): { sx: number; sy: number; sw: number; sh: number } { + const scale = coverScale(naturalWidth, naturalHeight, viewport) * zoom + const sw = viewport / scale + const sh = viewport / scale + const centerX = naturalWidth / 2 - offset.x / scale + const centerY = naturalHeight / 2 - offset.y / scale + return { + sx: centerX - sw / 2, + sy: centerY - sh / 2, + sw, + sh, + } +} diff --git a/frontend/src/features/account/i18n/en.ts b/frontend/src/features/account/i18n/en.ts index 284e422..3928f2e 100644 --- a/frontend/src/features/account/i18n/en.ts +++ b/frontend/src/features/account/i18n/en.ts @@ -8,6 +8,10 @@ export const accountEn = { }, avatar: { change: 'Change avatar', + reposition: 'Drag to reposition', + zoom: 'Zoom', + confirm: 'Confirm', + cancel: 'Cancel', }, validation: { avatarType: 'PNG, JPG or WebP please.', diff --git a/frontend/src/features/account/i18n/ru.ts b/frontend/src/features/account/i18n/ru.ts index a11d2bb..b83cc24 100644 --- a/frontend/src/features/account/i18n/ru.ts +++ b/frontend/src/features/account/i18n/ru.ts @@ -5,6 +5,10 @@ export const accountRu = { }, avatar: { change: 'Сменить аватар', + reposition: 'Перетащи, чтобы выбрать область', + zoom: 'Масштаб', + confirm: 'Подтвердить', + cancel: 'Отмена', }, validation: { avatarType: 'Подойдёт PNG, JPG или WebP.', diff --git a/frontend/src/features/account/tests/avatarUpload.test.tsx b/frontend/src/features/account/tests/avatarUpload.test.tsx new file mode 100644 index 0000000..56fb696 --- /dev/null +++ b/frontend/src/features/account/tests/avatarUpload.test.tsx @@ -0,0 +1,47 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { AvatarUpload } from '../components/AvatarUpload' +import { renderApp } from '../../../test/render' +import { json, mockFetch } from '../../../test/fetch' + +beforeEach(() => { + URL.createObjectURL = vi.fn(() => 'blob:mock') + URL.revokeObjectURL = vi.fn() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +test('picking a file opens the crop step instead of uploading immediately', async () => { + const user = userEvent.setup() + const fetch = mockFetch({ + 'POST /auth/refresh': () => json({}, 401), + 'POST /avatars': () => json({ avatar_url: 'x' }), + }) + renderApp([{ path: '/', element: }]) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['x'], 'a.png', { type: 'image/png' }) + await user.upload(input, file) + + expect(await screen.findByRole('button', { name: 'Подтвердить' })).toBeInTheDocument() + expect(fetch.mock.calls.some(([url]) => String(url).includes('/avatars'))).toBe(false) +}) + +test('cancelling the crop step returns to the picker without uploading', async () => { + const user = userEvent.setup() + const fetch = mockFetch({ + 'POST /auth/refresh': () => json({}, 401), + 'POST /avatars': () => json({ avatar_url: 'x' }), + }) + renderApp([{ path: '/', element: }]) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['x'], 'a.png', { type: 'image/png' }) + await user.upload(input, file) + + await user.click(await screen.findByRole('button', { name: 'Отмена' })) + + expect(await screen.findByText('Сменить аватар')).toBeInTheDocument() + expect(fetch.mock.calls.some(([url]) => String(url).includes('/avatars'))).toBe(false) +}) diff --git a/frontend/src/features/account/tests/cropMath.test.ts b/frontend/src/features/account/tests/cropMath.test.ts new file mode 100644 index 0000000..a2c4359 --- /dev/null +++ b/frontend/src/features/account/tests/cropMath.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from 'vitest' +import { clampPan, clampZoom, coverScale, sourceRect } from '../components/cropMath' + +test('coverScale scales the shorter side to fill the viewport', () => { + expect(coverScale(1000, 2000, 200)).toBeCloseTo(0.2) + expect(coverScale(2000, 1000, 200)).toBeCloseTo(0.2) +}) + +test('clampZoom keeps zoom within [1, 4]', () => { + expect(clampZoom(0.5)).toBe(1) + expect(clampZoom(10)).toBe(4) + expect(clampZoom(2)).toBe(2) +}) + +test('clampPan keeps a square image centered at zoom 1 (no room to pan)', () => { + const clamped = clampPan({ x: 50, y: 50 }, 400, 400, 200, 1) + expect(clamped).toEqual({ x: 0, y: 0 }) +}) + +test('clampPan allows panning up to the overflow at higher zoom', () => { + // 400x400 image, viewport 200, zoom 2 -> scale = 0.5*2 = 1, scaledSize 400, overflow (400-200)/2=100 + const clamped = clampPan({ x: 1000, y: -1000 }, 400, 400, 200, 2) + expect(clamped).toEqual({ x: 100, y: -100 }) +}) + +test('clampPan never lets the image show empty space (overflow stays >= 0)', () => { + const clamped = clampPan({ x: 5, y: 5 }, 300, 900, 240, 1) + // shorter side (300) covers the viewport exactly at zoom 1, so no pan room on x + expect(clamped.x).toBe(0) + // long side has plenty of room + expect(Math.abs(clamped.y)).toBeLessThanOrEqual(5) +}) + +test('sourceRect at zoom 1 with no offset centers the full covering square', () => { + const rect = sourceRect(400, 400, 200, 1, { x: 0, y: 0 }) + expect(rect.sx).toBeCloseTo(0) + expect(rect.sy).toBeCloseTo(0) + expect(rect.sw).toBeCloseTo(400) + expect(rect.sh).toBeCloseTo(400) +}) + +test('sourceRect shrinks the source square as zoom increases', () => { + const rect = sourceRect(400, 400, 200, 2, { x: 0, y: 0 }) + expect(rect.sw).toBeCloseTo(200) + expect(rect.sh).toBeCloseTo(200) + expect(rect.sx).toBeCloseTo(100) + expect(rect.sy).toBeCloseTo(100) +}) + +test('sourceRect shifts opposite the pan offset', () => { + const zoomed = sourceRect(400, 400, 200, 2, { x: 50, y: 0 }) + // positive offset.x pans the image right on screen -> source window moves left + expect(zoomed.sx).toBeLessThan(100) +}) diff --git a/frontend/src/features/landing/tests/landing.test.tsx b/frontend/src/features/landing/tests/landing.test.tsx index 98eab51..e0b8dac 100644 --- a/frontend/src/features/landing/tests/landing.test.tsx +++ b/frontend/src/features/landing/tests/landing.test.tsx @@ -37,5 +37,5 @@ test('english locale renders the landing hero headline', async () => { , ) - expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent('you can see') + expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent("you'll fall in love with") }) diff --git a/frontend/src/shared/layout/tests/layout.test.tsx b/frontend/src/shared/layout/tests/layout.test.tsx index 99de0a9..e30cb31 100644 --- a/frontend/src/shared/layout/tests/layout.test.tsx +++ b/frontend/src/shared/layout/tests/layout.test.tsx @@ -16,7 +16,7 @@ test('anonymous visitor sees nav and a login link', async () => { renderApp(routes, '/') expect(await screen.findByRole('link', { name: 'Войти' })).toHaveAttribute('href', '/login') expect(screen.getAllByRole('link', { name: 'Витрина' }).length).toBeGreaterThan(0) - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Визуалы, которые видно') + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Визуалы, в которые влюбляешься') }) test('signed-in visitor sees their nick instead of login', async () => { diff --git a/frontend/src/shared/layout/topbar/parts.tsx b/frontend/src/shared/layout/topbar/parts.tsx index 96d1515..77ca8bb 100644 --- a/frontend/src/shared/layout/topbar/parts.tsx +++ b/frontend/src/shared/layout/topbar/parts.tsx @@ -3,15 +3,45 @@ import { Link } from 'react-router' import { useSession } from '../../../features/auth/session' import type { Me } from '../../types' -/** The logo pill in the top bar; the in-game watermark's fps counter lives in the ClickGui replica. */ +/** The pixel-heart mark, standalone (no badge background) — same shape as public/favicon.svg. */ +function HeartMark({ className = 'size-6' }: { className?: string }) { + return ( + + ) +} + +/** The logo mark in the top bar; the in-game watermark's fps counter lives in the ClickGui replica. */ export function Watermark() { const { t } = useTranslation('common') return ( - + + LoVisual