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.zoom')}
+ onZoomChange(Number(e.target.value))}
+ />
+
+
{t('avatar.reposition')}
+
+
+ {t('avatar.cancel')}
+
+
+ {t('avatar.confirm')}
+
+
+
+ )
+}
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