chore(history): squash 16 commit(s) from 2026-09-26

- chore(backend): sync Cargo.lock with gateway dev-dependency
- feat(frontend): ClickGui replica matching the in-game GUI — category panels, Modules/Settings switch, theme cards
- feat(frontend): floating glass top bar in the mod's UI language
- fix(frontend): soft night backdrop for the ClickGui, editor that fits one screen
- chore(frontend): replace em dashes in UI copy with lighter separators
- refactor(frontend): split landing sections and theme editor into subfolders (4-files-per-folder rule)
- feat(frontend): login and registration in the mod's glass panel style
- feat(frontend): link the game with a device code
- feat(frontend): account page with avatar upload and linked games
- feat(frontend): my configs with share codes and publishing
- feat(frontend): public showcase listing and detail pages
- chore(frontend): drop em dashes from visible copy
- feat(frontend): atmospheric backdrop, glass download block and ClickGui-style FAQ on the landing
- feat(frontend): Konami code, DevTools console greeting and ASCII 404 block
- feat: public player profiles at /u/:id and author link in the showcase
- fix(frontend): stop the theme editor overflowing horizontally at 375px
This commit is contained in:
loki5512344 2026-09-26 15:26:37 +02:00
parent 7f4b532f99
commit ff2edc8eb6
116 changed files with 3534 additions and 1020 deletions

View file

@ -0,0 +1,27 @@
import { api } from '../../shared/api/client'
export type DeviceLink = { id: string; linked_at: string; last_seen: string | null }
const ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp'])
const MAX_BYTES = 5 * 1024 * 1024
/** Client-side avatar checks so a bad file never reaches the server. */
export function checkAvatarFile(file: File): string | undefined {
if (!ALLOWED_TYPES.has(file.type)) return 'Подойдёт PNG, JPG или WebP.'
if (file.size > MAX_BYTES) return 'Файл больше 5 МБ.'
return undefined
}
export async function uploadAvatar(file: File): Promise<{ avatar_url: string }> {
const body = new FormData()
body.set('file', file)
return api.request('/avatars', { method: 'POST', body })
}
export async function listDevices(): Promise<DeviceLink[]> {
return api.request('/device/links')
}
export async function revokeDevice(id: string): Promise<void> {
await api.request(`/device/links/${id}`, { method: 'DELETE' })
}

View file

@ -0,0 +1,49 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Notice } from '../../../shared/ui/Notice'
import { checkAvatarFile, uploadAvatar } from '../api'
/** A label-based file picker (keyboard reachable) that uploads and refreshes the cached profile. */
export function AvatarUpload() {
const { t } = useTranslation('account')
const id = useId()
const client = useQueryClient()
const [error, setError] = useState<string>()
const mutation = useMutation({
mutationFn: uploadAvatar,
onSuccess: () => client.invalidateQueries({ queryKey: ['me'] }),
})
function onChange(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const problem = checkAvatarFile(file)
if (problem) {
setError(problem)
return
}
setError(undefined)
mutation.mutate(file)
}
return (
<div className="flex flex-col items-center gap-2">
<label
htmlFor={id}
className="cursor-pointer rounded-md border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
>
{t('avatar.change')}
<input
id={id}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={onChange}
/>
</label>
{error ? <Notice tone="error">{error}</Notice> : null}
</div>
)
}

View file

@ -0,0 +1,72 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { listDevices, revokeDevice, type DeviceLink } from '../api'
function relativeDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
}
function DeviceRow({ device }: { device: DeviceLink }) {
const { t } = useTranslation('account')
const client = useQueryClient()
const [confirming, setConfirming] = useState(false)
const mutation = useMutation({
mutationFn: () => revokeDevice(device.id),
onSuccess: () => client.invalidateQueries({ queryKey: ['devices'] }),
})
return (
<li className="flex items-center justify-between gap-4 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-3">
<div>
<p className="text-sm text-snow">{t('devices.linkedAt', { date: relativeDate(device.linked_at) })}</p>
<p className="text-sm text-frost">
{t('devices.lastSeen', { when: device.last_seen ? relativeDate(device.last_seen) : t('devices.neverSeen') })}
</p>
</div>
{confirming ? (
<div className="flex items-center gap-2">
<Button variant="danger" busy={mutation.isPending} onClick={() => mutation.mutate()}>
{t('devices.confirmRevoke')}
</Button>
<Button variant="quiet" onClick={() => setConfirming(false)}>
{t('devices.cancel')}
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirming(true)}>
{t('devices.revoke')}
</Button>
)}
</li>
)
}
/** ClickGui-like rows listing devices the mod linked, each with a two-step confirm to revoke. */
export function DeviceList() {
const { t } = useTranslation('account')
const query = useQuery({ queryKey: ['devices'], queryFn: listDevices })
const devices = query.data ?? []
return (
<div className="rounded-(--radius-panel) border border-shoal bg-slate/40 p-5">
<h2 className="font-display text-lg">{t('devices.heading')}</h2>
{devices.length === 0 ? (
<p className="mt-3 text-sm text-frost">
{t('devices.empty')}{' '}
<Link to="/link" className="text-ice underline-offset-4 hover:underline">
{t('devices.emptyLink')}
</Link>
</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{devices.map((device) => (
<DeviceRow key={device.id} device={device} />
))}
</ul>
)}
</div>
)
}

View file

@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { useSession } from '../../auth/session'
import { AvatarUpload } from './AvatarUpload'
/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. */
export function ProfileCard() {
const { t } = useTranslation('account')
const { me, signOut } = useSession()
const navigate = useNavigate()
if (!me) return null
const joined = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(me.created_at))
async function handleSignOut() {
await signOut()
navigate('/')
}
return (
<div className="flex flex-col items-center gap-4 rounded-(--radius-panel) border border-shoal bg-slate/70 p-6 text-center">
<div className="relative">
<div className="flex h-24 w-24 items-center justify-center overflow-hidden rounded-full border-2 border-ice bg-abyss font-display text-3xl">
{me.avatar_url ? (
<img src={me.avatar_url} alt="" className="h-full w-full object-cover" />
) : (
<span aria-hidden="true">{me.display_nick.slice(0, 1).toUpperCase()}</span>
)}
</div>
</div>
<AvatarUpload />
<div>
<p className="font-display text-xl">{me.display_nick}</p>
<p className="text-sm text-frost">{me.email}</p>
<p className="mt-1 text-sm text-frost">{t('profile.memberSince', { date: joined })}</p>
</div>
<Button variant="quiet" onClick={handleSignOut}>
{t('profile.signOut')}
</Button>
</div>
)
}

View file

@ -0,0 +1,23 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { accountRu } from './ru'
export const accountEn = {
profile: {
memberSince: 'With us since {{date}}',
signOut: 'Sign out',
},
avatar: {
change: 'Change avatar',
},
devices: {
heading: 'Linked games',
linkedAt: 'Linked {{date}}',
lastSeen: 'Last seen: {{when}}',
neverSeen: 'never signed in yet',
revoke: 'Unlink',
confirmRevoke: 'Really unlink?',
cancel: 'Cancel',
empty: 'No linked games yet. Link the mod on the "Link the game" page.',
emptyLink: 'Link the game',
},
} satisfies Translation<typeof accountRu>

View file

@ -0,0 +1,20 @@
export const accountRu = {
profile: {
memberSince: 'С нами с {{date}}',
signOut: 'Выйти',
},
avatar: {
change: 'Сменить аватар',
},
devices: {
heading: 'Привязанные игры',
linkedAt: 'Привязано {{date}}',
lastSeen: 'Последний вход: {{when}}',
neverSeen: 'ещё не заходил',
revoke: 'Отвязать',
confirmRevoke: 'Точно отвязать?',
cancel: 'Отмена',
empty: 'Пока ни одной игры. Привяжи мод на странице «Привязать игру».',
emptyLink: 'Привязать игру',
},
} as const

View file

@ -0,0 +1,33 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { checkAvatarFile } from '../api'
import { DeviceList } from '../components/DeviceList'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
test('avatar file checks', () => {
expect(checkAvatarFile(new File(['x'], 'a.gif', { type: 'image/gif' }))).toBe('Подойдёт PNG, JPG или WebP.')
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'a.png', { type: 'image/png' })
expect(checkAvatarFile(big)).toBe('Файл больше 5 МБ.')
expect(checkAvatarFile(new File(['x'], 'a.png', { type: 'image/png' }))).toBeUndefined()
})
test('revoking a device asks for confirmation first', async () => {
const user = userEvent.setup()
let devices = [{ id: 'd1', linked_at: '2026-09-20T10:00:00Z', last_seen: null }]
const fetch = mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /device/links': () => json(devices),
'DELETE /device/links/d1': () => {
devices = []
return new Response(null, { status: 204 })
},
})
renderApp([{ path: '/', element: <DeviceList /> }])
const row = await screen.findByRole('listitem')
await user.click(within(row).getByRole('button', { name: 'Отвязать' }))
expect(fetch.mock.calls.some(([, i]) => i?.method === 'DELETE')).toBe(false)
await user.click(within(row).getByRole('button', { name: 'Точно отвязать?' }))
expect(await screen.findByText(/Пока ни одной игры/)).toBeInTheDocument()
})