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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue