fix(frontend): session cookie path, browser-language default, avatar crop, EN link previews, topbar logo
- nginx: rewrite the refresh cookie's Path=/auth to /api/auth on the production proxy — without this the browser never sent the cookie back on POST /api/auth/refresh and the session was lost on reload - i18n: fallbackLng ru -> en, so only an actual ru browser locale defaults to Russian, everything else defaults to English - index.html: explicit English og:*/twitter:* tags so link previews in Discord/etc. don't fall back to the (Russian) meta description - Avatar upload: pick -> circular crop/reposition/zoom preview -> confirm -> upload, instead of uploading the raw file blind - Topbar: logo pill background removed, pixel-heart mark added next to the LoVisual wordmark - Fixed two tests left stale by the earlier tagline copy change Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
parent
fc0e125e62
commit
e0531eda5e
13 changed files with 383 additions and 16 deletions
|
|
@ -10,6 +10,10 @@ server {
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 / {
|
location / {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,34 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0d1416" />
|
<meta name="theme-color" content="#0d1416" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="LoVisual, визуальный мод для Minecraft, в который влюбляешься. Крылья, 3D меню, погода с отражениями и облачные конфиги."
|
content="LoVisual, a Minecraft visuals mod you'll fall in love with. Wings, a floating 3D menu, rain with reflections and cloud configs."
|
||||||
/>
|
/>
|
||||||
<title>LoVisual · визуалы, в которые влюбляешься</title>
|
<title>LoVisual, visuals you'll fall in love with</title>
|
||||||
|
|
||||||
|
<!-- Link previews (Discord, Slack, Telegram, X, ...) read these instead of <meta name=description>,
|
||||||
|
and render before the site's own JS-driven i18n can run, so they stay in English on purpose. -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="LoVisual" />
|
||||||
|
<meta property="og:title" content="LoVisual, visuals you'll fall in love with" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="A free legit Minecraft visuals mod. Wings, a floating 3D menu, rain with reflections and cloud configs."
|
||||||
|
/>
|
||||||
|
<meta property="og:url" content="https://visual.loki-code.dev/" />
|
||||||
|
<meta property="og:image" content="https://visual.loki-code.dev/favicon.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:title" content="LoVisual, visuals you'll fall in love with" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="A free legit Minecraft visuals mod. Wings, a floating 3D menu, rain with reflections and cloud configs."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="https://visual.loki-code.dev/favicon.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,9 @@ export function initI18n(lng?: 'ru' | 'en'): i18n {
|
||||||
profile: profileEn,
|
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'],
|
supportedLngs: ['ru', 'en'],
|
||||||
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'],
|
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'],
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
|
|
|
||||||
124
frontend/src/features/account/components/AvatarCropper.tsx
Normal file
124
frontend/src/features/account/components/AvatarCropper.tsx
Normal file
|
|
@ -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<HTMLImageElement>) {
|
||||||
|
setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
drag.current = { x: e.clientX, y: e.clientY, offset }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-full border border-shoal touch-none"
|
||||||
|
style={{ width: VIEWPORT, height: VIEWPORT, cursor: 'grab' }}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
onLoad={onImgLoad}
|
||||||
|
className="pointer-events-none absolute select-none"
|
||||||
|
style={{
|
||||||
|
width: drawnW,
|
||||||
|
height: drawnH,
|
||||||
|
left: VIEWPORT / 2 - drawnW / 2 + offset.x,
|
||||||
|
top: VIEWPORT / 2 - drawnH / 2 + offset.y,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex w-full max-w-[240px] flex-col gap-1 text-sm text-snow">
|
||||||
|
{t('avatar.zoom')}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={MIN_ZOOM}
|
||||||
|
max={MAX_ZOOM}
|
||||||
|
step={0.01}
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => onZoomChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-shoal">{t('avatar.reposition')}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="quiet" onClick={onCancel} disabled={busy}>
|
||||||
|
{t('avatar.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={confirm} busy={busy} disabled={!natural}>
|
||||||
|
{t('avatar.confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,16 +3,21 @@ import { useId, useState, type ChangeEvent } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Notice } from '../../../shared/ui/Notice'
|
import { Notice } from '../../../shared/ui/Notice'
|
||||||
import { checkAvatarFile, uploadAvatar } from '../api'
|
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() {
|
export function AvatarUpload() {
|
||||||
const { t } = useTranslation('account')
|
const { t } = useTranslation('account')
|
||||||
const id = useId()
|
const id = useId()
|
||||||
const client = useQueryClient()
|
const client = useQueryClient()
|
||||||
const [error, setError] = useState<string>()
|
const [error, setError] = useState<string>()
|
||||||
|
const [picked, setPicked] = useState<File>()
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: uploadAvatar,
|
mutationFn: uploadAvatar,
|
||||||
onSuccess: () => client.invalidateQueries({ queryKey: ['me'] }),
|
onSuccess: () => {
|
||||||
|
client.invalidateQueries({ queryKey: ['me'] })
|
||||||
|
setPicked(undefined)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function onChange(e: ChangeEvent<HTMLInputElement>) {
|
function onChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
|
@ -25,7 +30,21 @@ export function AvatarUpload() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setError(undefined)
|
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 (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<AvatarCropper file={picked} onCancel={() => setPicked(undefined)} onConfirm={onConfirmCrop} />
|
||||||
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
60
frontend/src/features/account/components/cropMath.ts
Normal file
60
frontend/src/features/account/components/cropMath.ts
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,10 @@ export const accountEn = {
|
||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
change: 'Change avatar',
|
change: 'Change avatar',
|
||||||
|
reposition: 'Drag to reposition',
|
||||||
|
zoom: 'Zoom',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
cancel: 'Cancel',
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
avatarType: 'PNG, JPG or WebP please.',
|
avatarType: 'PNG, JPG or WebP please.',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ export const accountRu = {
|
||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
change: 'Сменить аватар',
|
change: 'Сменить аватар',
|
||||||
|
reposition: 'Перетащи, чтобы выбрать область',
|
||||||
|
zoom: 'Масштаб',
|
||||||
|
confirm: 'Подтвердить',
|
||||||
|
cancel: 'Отмена',
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
avatarType: 'Подойдёт PNG, JPG или WebP.',
|
avatarType: 'Подойдёт PNG, JPG или WebP.',
|
||||||
|
|
|
||||||
47
frontend/src/features/account/tests/avatarUpload.test.tsx
Normal file
47
frontend/src/features/account/tests/avatarUpload.test.tsx
Normal file
|
|
@ -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: <AvatarUpload /> }])
|
||||||
|
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: <AvatarUpload /> }])
|
||||||
|
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)
|
||||||
|
})
|
||||||
54
frontend/src/features/account/tests/cropMath.test.ts
Normal file
54
frontend/src/features/account/tests/cropMath.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
@ -37,5 +37,5 @@ test('english locale renders the landing hero headline', async () => {
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</I18nextProvider>,
|
</I18nextProvider>,
|
||||||
)
|
)
|
||||||
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")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ test('anonymous visitor sees nav and a login link', async () => {
|
||||||
renderApp(routes, '/')
|
renderApp(routes, '/')
|
||||||
expect(await screen.findByRole('link', { name: 'Войти' })).toHaveAttribute('href', '/login')
|
expect(await screen.findByRole('link', { name: 'Войти' })).toHaveAttribute('href', '/login')
|
||||||
expect(screen.getAllByRole('link', { name: 'Витрина' }).length).toBeGreaterThan(0)
|
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 () => {
|
test('signed-in visitor sees their nick instead of login', async () => {
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,45 @@ import { Link } from 'react-router'
|
||||||
import { useSession } from '../../../features/auth/session'
|
import { useSession } from '../../../features/auth/session'
|
||||||
import type { Me } from '../../types'
|
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 (
|
||||||
|
<svg viewBox="0 0 31 27" className={className} shapeRendering="crispEdges" aria-hidden="true">
|
||||||
|
<g>
|
||||||
|
<path fill="#be185d" d="M12 0 12 4 14.9 6.9 14.9 2.9Z" />
|
||||||
|
<path fill="#be185d" d="M24 0 24 4 26.9 6.9 26.9 2.9Z" />
|
||||||
|
<path fill="#be185d" d="M28 4 28 12 30.9 14.9 30.9 6.9Z" />
|
||||||
|
<path fill="#be185d" d="M24 12 24 16 26.9 18.9 26.9 14.9Z" />
|
||||||
|
<path fill="#be185d" d="M20 16 20 20 22.9 22.9 22.9 18.9Z" />
|
||||||
|
<path fill="#be185d" d="M16 20 16 24 18.9 26.9 18.9 22.9Z" />
|
||||||
|
<path fill="#701a3e" d="M28 12 24 12 26.9 14.9 30.9 14.9Z" />
|
||||||
|
<path fill="#701a3e" d="M24 16 20 16 22.9 18.9 26.9 18.9Z" />
|
||||||
|
<path fill="#701a3e" d="M20 20 16 20 18.9 22.9 22.9 22.9Z" />
|
||||||
|
<path fill="#701a3e" d="M16 24 12 24 14.9 26.9 18.9 26.9Z" />
|
||||||
|
<path fill="#701a3e" d="M12 20 8 20 10.9 22.9 14.9 22.9Z" />
|
||||||
|
<path fill="#701a3e" d="M8 16 4 16 6.9 18.9 10.9 18.9Z" />
|
||||||
|
<path fill="#701a3e" d="M4 12 0 12 2.9 14.9 6.9 14.9Z" />
|
||||||
|
<path
|
||||||
|
fill="#f472b6"
|
||||||
|
d="M4 0 12 0 12 4 16 4 16 0 24 0 24 4 28 4 28 12 24 12 24 16 20 16 20 20 16 20 16 24 12 24 12 20 8 20 8 16 4 16 4 12 0 12 0 4 4 4Z"
|
||||||
|
/>
|
||||||
|
<path fill="#701a3e" d="M4 4H8V8H4Z" />
|
||||||
|
<path fill="#701a3e" d="M4 8H12V12H4Z" />
|
||||||
|
<path fill="#701a3e" d="M8 12H12V16H8Z" />
|
||||||
|
<path fill="#701a3e" d="M20 4H24V8H20Z" />
|
||||||
|
<path fill="#701a3e" d="M16 8H24V12H16Z" />
|
||||||
|
<path fill="#701a3e" d="M16 12H20V16H16Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The logo mark in the top bar; the in-game watermark's fps counter lives in the ClickGui replica. */
|
||||||
export function Watermark() {
|
export function Watermark() {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link to="/" aria-label={t('nav.home')} className="group flex h-9 shrink-0 items-center gap-2 px-1">
|
||||||
to="/"
|
<HeartMark />
|
||||||
aria-label={t('nav.home')}
|
|
||||||
className="group flex h-9 shrink-0 items-center gap-1.5 rounded-xl border border-white/10 bg-black/45 px-3 shadow-[inset_0_1px_0_rgb(255_255_255/0.06)] transition-colors hover:border-white/20"
|
|
||||||
>
|
|
||||||
<span className="font-display text-[15px] font-bold tracking-tight text-ice drop-shadow-[0_0_10px_var(--page-accent-glow)]">
|
<span className="font-display text-[15px] font-bold tracking-tight text-ice drop-shadow-[0_0_10px_var(--page-accent-glow)]">
|
||||||
LoVisual
|
LoVisual
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue