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:
loki5512344 2026-09-28 22:23:28 +02:00
parent fc0e125e62
commit e0531eda5e
Signed by: boba
GPG key ID: 253067914055423B
13 changed files with 383 additions and 16 deletions

View 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>
)
}

View file

@ -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<string>()
const [picked, setPicked] = useState<File>()
const mutation = useMutation({
mutationFn: uploadAvatar,
onSuccess: () => client.invalidateQueries({ queryKey: ['me'] }),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['me'] })
setPicked(undefined)
},
})
function onChange(e: ChangeEvent<HTMLInputElement>) {
@ -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 (
<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 (

View 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,
}
}