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:
parent
7f4b532f99
commit
ff2edc8eb6
116 changed files with 3534 additions and 1020 deletions
27
frontend/src/features/account/api.ts
Normal file
27
frontend/src/features/account/api.ts
Normal 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' })
|
||||
}
|
||||
49
frontend/src/features/account/components/AvatarUpload.tsx
Normal file
49
frontend/src/features/account/components/AvatarUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
frontend/src/features/account/components/DeviceList.tsx
Normal file
72
frontend/src/features/account/components/DeviceList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
frontend/src/features/account/components/ProfileCard.tsx
Normal file
44
frontend/src/features/account/components/ProfileCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
frontend/src/features/account/i18n/en.ts
Normal file
23
frontend/src/features/account/i18n/en.ts
Normal 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>
|
||||
20
frontend/src/features/account/i18n/ru.ts
Normal file
20
frontend/src/features/account/i18n/ru.ts
Normal 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
|
||||
33
frontend/src/features/account/tests/account.test.tsx
Normal file
33
frontend/src/features/account/tests/account.test.tsx
Normal 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()
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue