From ff2edc8eb6d5e042ebf0066238d9b7c3f0f0e302 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sat, 26 Sep 2026 15:26:37 +0200 Subject: [PATCH] chore(history): squash 16 commit(s) from 2026-09-26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/Cargo.lock | 1 + .../configs-service/src/showcase/handlers.rs | 2 + frontend/index.html | 2 +- frontend/src/app/i18n.ts | 40 ++- frontend/src/app/i18next.d.ts | 10 + frontend/src/app/routes.tsx | 18 +- frontend/src/features/account/api.ts | 27 ++ .../account/components/AvatarUpload.tsx | 49 +++ .../account/components/DeviceList.tsx | 72 ++++ .../account/components/ProfileCard.tsx | 44 +++ frontend/src/features/account/i18n/en.ts | 23 ++ frontend/src/features/account/i18n/ru.ts | 20 ++ .../features/account/tests/account.test.tsx | 33 ++ frontend/src/features/auth/i18n/en.ts | 1 + frontend/src/features/auth/i18n/ru.ts | 1 + .../src/features/clickgui/ClickGuiWindow.tsx | 152 ++++---- frontend/src/features/clickgui/ModuleCard.tsx | 59 ---- frontend/src/features/clickgui/demo.ts | 154 ++++----- frontend/src/features/clickgui/i18n/en.ts | 53 +-- frontend/src/features/clickgui/i18n/ru.ts | 53 +-- .../src/features/clickgui/parts/Backdrop.tsx | 128 +++++++ .../src/features/clickgui/parts/Glyphs.tsx | 125 ++++--- .../clickgui/parts/SettingControls.tsx | 92 ----- .../src/features/clickgui/parts/clickgui.css | 325 +++++++++--------- .../src/features/clickgui/parts/modules.ts | 122 +++---- .../clickgui/screens/ModulesScreen.tsx | 56 +++ .../clickgui/screens/SettingsScreen.tsx | 103 ++++++ .../src/features/clickgui/screens/screens.css | 248 +++++++++++++ .../features/clickgui/tests/clickgui.test.tsx | 92 +++-- frontend/src/features/clickgui/themeVars.ts | 41 ++- frontend/src/features/configs/api.ts | 18 + .../configs/components/PublishForm.tsx | 73 ++++ .../features/configs/components/SlotGrid.tsx | 21 ++ .../features/configs/components/SlotPanel.tsx | 124 +++++++ frontend/src/features/configs/i18n/en.ts | 33 ++ frontend/src/features/configs/i18n/ru.ts | 33 ++ .../features/configs/tests/configs.test.tsx | 64 ++++ frontend/src/features/configs/types.ts | 7 + .../features/device-link/LinkDeviceForm.tsx | 62 ++++ frontend/src/features/device-link/api.ts | 13 + frontend/src/features/device-link/i18n/en.ts | 20 ++ frontend/src/features/device-link/i18n/ru.ts | 17 + .../features/device-link/tests/link.test.tsx | 38 ++ frontend/src/features/download/i18n/en.ts | 6 +- frontend/src/features/download/i18n/ru.ts | 6 +- frontend/src/features/landing/CommandHero.tsx | 2 +- .../src/features/landing/effects/Tilt.tsx | 47 --- frontend/src/features/landing/i18n/en.ts | 14 +- frontend/src/features/landing/i18n/ru.ts | 24 +- .../sections/{ => explore}/FaqDownload.tsx | 15 +- .../sections/{ => explore}/HudPlayground.tsx | 55 ++- .../sections/{ => explore}/ModuleWall.tsx | 25 +- .../sections/{ => explore}/ShowcaseTeaser.tsx | 27 +- .../landing/sections/{ => hero}/Hero.tsx | 29 +- .../sections/{ => hero}/ThemeStrip.tsx | 14 +- .../src/features/landing/styles/landing.css | 57 ++- .../features/landing/tests/landing.test.tsx | 2 +- .../features/landing/tests/sections.test.tsx | 8 +- frontend/src/features/profile/api.ts | 13 + .../profile/components/PublicProfileView.tsx | 45 +++ frontend/src/features/profile/i18n/en.ts | 7 + frontend/src/features/profile/i18n/ru.ts | 7 + .../features/profile/tests/profile.test.tsx | 27 ++ frontend/src/features/showcase/api.ts | 16 + .../showcase/components/CopyToSlot.tsx | 75 ++++ .../showcase/components/ListingDetail.tsx | 53 +++ .../showcase/components/ListingItem.tsx | 29 ++ .../showcase/components/ListingList.tsx | 81 +++++ frontend/src/features/showcase/copies.ts | 17 + frontend/src/features/showcase/i18n/en.ts | 32 ++ frontend/src/features/showcase/i18n/ru.ts | 32 ++ .../features/showcase/tests/showcase.test.tsx | 87 +++++ frontend/src/features/showcase/types.ts | 16 + .../features/themes/editor/EditorPreview.tsx | 22 +- .../features/themes/editor/ThemeEditor.tsx | 34 +- .../src/features/themes/editor/editor.css | 10 + .../themes/editor/{ => fields}/ColorField.tsx | 12 +- .../editor/{ => fields}/GradientField.tsx | 4 +- .../themes/editor/{ => model}/codec.ts | 4 +- .../editor/{ => model}/useThemeHistory.ts | 2 +- .../themes/editor/tests/codec.test.ts | 2 +- .../themes/editor/tests/editor.test.tsx | 25 +- frontend/src/features/themes/i18n/en.ts | 2 +- frontend/src/features/themes/i18n/ru.ts | 2 +- frontend/src/index.css | 7 + frontend/src/main.tsx | 2 + frontend/src/pages/app/AccountPage.tsx | 11 + frontend/src/pages/app/ConfigsPage.tsx | 30 ++ frontend/src/pages/app/LinkPage.tsx | 35 ++ frontend/src/pages/public/HomePage.tsx | 27 +- frontend/src/pages/public/LoginPage.tsx | 6 +- frontend/src/pages/public/NotFoundPage.tsx | 5 + frontend/src/pages/public/ProfilePage.tsx | 11 + frontend/src/pages/public/RegisterPage.tsx | 8 +- frontend/src/pages/public/auth/AuthSplit.tsx | 36 ++ frontend/src/pages/showcase/ListingPage.tsx | 21 ++ frontend/src/pages/showcase/ListingRoute.tsx | 11 + frontend/src/pages/showcase/ShowcasePage.tsx | 27 ++ frontend/src/pages/showcase/ShowcaseRoute.tsx | 12 + frontend/src/pages/themes/ThemesPage.tsx | 18 +- frontend/src/shared/easter/KonamiEffect.tsx | 20 ++ frontend/src/shared/easter/art.generated.ts | 3 + frontend/src/shared/easter/consoleGreeting.ts | 14 + .../src/shared/easter/tests/konami.test.tsx | 20 ++ frontend/src/shared/easter/useKonami.ts | 29 ++ frontend/src/shared/i18n/LanguageSwitch.tsx | 17 +- frontend/src/shared/i18n/common.en.ts | 7 + frontend/src/shared/i18n/common.ru.ts | 7 + frontend/src/shared/layout/AppShell.tsx | 8 +- frontend/src/shared/layout/Footer.tsx | 4 +- frontend/src/shared/layout/TopBar.tsx | 94 ----- .../src/shared/layout/tests/layout.test.tsx | 62 +++- .../src/shared/layout/topbar/MobileMenu.tsx | 115 +++++++ frontend/src/shared/layout/topbar/NavPill.tsx | 45 +++ frontend/src/shared/layout/topbar/TopBar.tsx | 100 ++++++ frontend/src/shared/layout/topbar/parts.tsx | 86 +++++ 116 files changed, 3534 insertions(+), 1020 deletions(-) create mode 100644 frontend/src/features/account/api.ts create mode 100644 frontend/src/features/account/components/AvatarUpload.tsx create mode 100644 frontend/src/features/account/components/DeviceList.tsx create mode 100644 frontend/src/features/account/components/ProfileCard.tsx create mode 100644 frontend/src/features/account/i18n/en.ts create mode 100644 frontend/src/features/account/i18n/ru.ts create mode 100644 frontend/src/features/account/tests/account.test.tsx delete mode 100644 frontend/src/features/clickgui/ModuleCard.tsx create mode 100644 frontend/src/features/clickgui/parts/Backdrop.tsx delete mode 100644 frontend/src/features/clickgui/parts/SettingControls.tsx create mode 100644 frontend/src/features/clickgui/screens/ModulesScreen.tsx create mode 100644 frontend/src/features/clickgui/screens/SettingsScreen.tsx create mode 100644 frontend/src/features/clickgui/screens/screens.css create mode 100644 frontend/src/features/configs/api.ts create mode 100644 frontend/src/features/configs/components/PublishForm.tsx create mode 100644 frontend/src/features/configs/components/SlotGrid.tsx create mode 100644 frontend/src/features/configs/components/SlotPanel.tsx create mode 100644 frontend/src/features/configs/i18n/en.ts create mode 100644 frontend/src/features/configs/i18n/ru.ts create mode 100644 frontend/src/features/configs/tests/configs.test.tsx create mode 100644 frontend/src/features/configs/types.ts create mode 100644 frontend/src/features/device-link/LinkDeviceForm.tsx create mode 100644 frontend/src/features/device-link/api.ts create mode 100644 frontend/src/features/device-link/i18n/en.ts create mode 100644 frontend/src/features/device-link/i18n/ru.ts create mode 100644 frontend/src/features/device-link/tests/link.test.tsx delete mode 100644 frontend/src/features/landing/effects/Tilt.tsx rename frontend/src/features/landing/sections/{ => explore}/FaqDownload.tsx (71%) rename frontend/src/features/landing/sections/{ => explore}/HudPlayground.tsx (81%) rename frontend/src/features/landing/sections/{ => explore}/ModuleWall.tsx (70%) rename frontend/src/features/landing/sections/{ => explore}/ShowcaseTeaser.tsx (64%) rename frontend/src/features/landing/sections/{ => hero}/Hero.tsx (79%) rename frontend/src/features/landing/sections/{ => hero}/ThemeStrip.tsx (90%) create mode 100644 frontend/src/features/profile/api.ts create mode 100644 frontend/src/features/profile/components/PublicProfileView.tsx create mode 100644 frontend/src/features/profile/i18n/en.ts create mode 100644 frontend/src/features/profile/i18n/ru.ts create mode 100644 frontend/src/features/profile/tests/profile.test.tsx create mode 100644 frontend/src/features/showcase/api.ts create mode 100644 frontend/src/features/showcase/components/CopyToSlot.tsx create mode 100644 frontend/src/features/showcase/components/ListingDetail.tsx create mode 100644 frontend/src/features/showcase/components/ListingItem.tsx create mode 100644 frontend/src/features/showcase/components/ListingList.tsx create mode 100644 frontend/src/features/showcase/copies.ts create mode 100644 frontend/src/features/showcase/i18n/en.ts create mode 100644 frontend/src/features/showcase/i18n/ru.ts create mode 100644 frontend/src/features/showcase/tests/showcase.test.tsx create mode 100644 frontend/src/features/showcase/types.ts rename frontend/src/features/themes/editor/{ => fields}/ColorField.tsx (88%) rename frontend/src/features/themes/editor/{ => fields}/GradientField.tsx (91%) rename frontend/src/features/themes/editor/{ => model}/codec.ts (97%) rename frontend/src/features/themes/editor/{ => model}/useThemeHistory.ts (98%) create mode 100644 frontend/src/pages/app/AccountPage.tsx create mode 100644 frontend/src/pages/app/ConfigsPage.tsx create mode 100644 frontend/src/pages/app/LinkPage.tsx create mode 100644 frontend/src/pages/public/ProfilePage.tsx create mode 100644 frontend/src/pages/public/auth/AuthSplit.tsx create mode 100644 frontend/src/pages/showcase/ListingPage.tsx create mode 100644 frontend/src/pages/showcase/ListingRoute.tsx create mode 100644 frontend/src/pages/showcase/ShowcasePage.tsx create mode 100644 frontend/src/pages/showcase/ShowcaseRoute.tsx create mode 100644 frontend/src/shared/easter/KonamiEffect.tsx create mode 100644 frontend/src/shared/easter/art.generated.ts create mode 100644 frontend/src/shared/easter/consoleGreeting.ts create mode 100644 frontend/src/shared/easter/tests/konami.test.tsx create mode 100644 frontend/src/shared/easter/useKonami.ts delete mode 100644 frontend/src/shared/layout/TopBar.tsx create mode 100644 frontend/src/shared/layout/topbar/MobileMenu.tsx create mode 100644 frontend/src/shared/layout/topbar/NavPill.tsx create mode 100644 frontend/src/shared/layout/topbar/TopBar.tsx create mode 100644 frontend/src/shared/layout/topbar/parts.tsx diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 6cc4a51..4d37549 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1589,6 +1589,7 @@ dependencies = [ "common", "dotenvy", "governor", + "http-body-util", "jsonwebtoken", "reqwest", "serde_json", diff --git a/backend/configs-service/src/showcase/handlers.rs b/backend/configs-service/src/showcase/handlers.rs index 781cb5f..a1776fe 100644 --- a/backend/configs-service/src/showcase/handlers.rs +++ b/backend/configs-service/src/showcase/handlers.rs @@ -24,6 +24,7 @@ pub struct Listing { pub copies_count: i32, pub published_at: DateTime, pub author: Option, + pub author_id: Option, } fn to_listing(row: ListingRow, author: Option) -> Listing { @@ -34,6 +35,7 @@ fn to_listing(row: ListingRow, author: Option) -> Listing { config_name: row.name, copies_count: row.copies_count, published_at: row.published_at, + author_id: author.as_ref().map(|_| row.account_id), author, } } diff --git a/frontend/index.html b/frontend/index.html index 6f34f9f..8508bba 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@ name="description" content="Облачные конфиги, коды для друзей и витрина настроек для мода LoVisual." /> - LoVisual — аккаунт и облачные конфиги + LoVisual · аккаунт и облачные конфиги
diff --git a/frontend/src/app/i18n.ts b/frontend/src/app/i18n.ts index dc1e7fd..d67260b 100644 --- a/frontend/src/app/i18n.ts +++ b/frontend/src/app/i18n.ts @@ -1,12 +1,22 @@ import i18next, { type i18n } from 'i18next' import LanguageDetector from 'i18next-browser-languagedetector' import { initReactI18next } from 'react-i18next' +import { accountEn } from '../features/account/i18n/en' +import { accountRu } from '../features/account/i18n/ru' import { authEn } from '../features/auth/i18n/en' import { authRu } from '../features/auth/i18n/ru' import { clickguiEn } from '../features/clickgui/i18n/en' +import { configsEn } from '../features/configs/i18n/en' import { clickguiRu } from '../features/clickgui/i18n/ru' +import { configsRu } from '../features/configs/i18n/ru' +import { deviceLinkEn } from '../features/device-link/i18n/en' +import { deviceLinkRu } from '../features/device-link/i18n/ru' import { downloadEn } from '../features/download/i18n/en' +import { profileEn } from '../features/profile/i18n/en' +import { profileRu } from '../features/profile/i18n/ru' +import { showcaseEn } from '../features/showcase/i18n/en' import { downloadRu } from '../features/download/i18n/ru' +import { showcaseRu } from '../features/showcase/i18n/ru' import { landingEn } from '../features/landing/i18n/en' import { landingRu } from '../features/landing/i18n/ru' import { themesEn } from '../features/themes/i18n/en' @@ -23,12 +33,36 @@ export function initI18n(lng?: 'ru' | 'en'): i18n { .init({ lng, resources: { - ru: { common: commonRu, auth: authRu, landing: landingRu, themes: themesRu, clickgui: clickguiRu, download: downloadRu }, - en: { common: commonEn, auth: authEn, landing: landingEn, themes: themesEn, clickgui: clickguiEn, download: downloadEn }, + ru: { + common: commonRu, + auth: authRu, + landing: landingRu, + themes: themesRu, + clickgui: clickguiRu, + download: downloadRu, + deviceLink: deviceLinkRu, + account: accountRu, + configs: configsRu, + showcase: showcaseRu, + profile: profileRu, + }, + en: { + common: commonEn, + auth: authEn, + landing: landingEn, + themes: themesEn, + clickgui: clickguiEn, + download: downloadEn, + deviceLink: deviceLinkEn, + account: accountEn, + configs: configsEn, + showcase: showcaseEn, + profile: profileEn, + }, }, fallbackLng: 'ru', supportedLngs: ['ru', 'en'], - ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download'], + ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'], defaultNS: 'common', interpolation: { escapeValue: false }, detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' }, diff --git a/frontend/src/app/i18next.d.ts b/frontend/src/app/i18next.d.ts index 81ef806..4fb3309 100644 --- a/frontend/src/app/i18next.d.ts +++ b/frontend/src/app/i18next.d.ts @@ -1,7 +1,12 @@ +import type { accountRu } from '../features/account/i18n/ru' import type { authRu } from '../features/auth/i18n/ru' import type { clickguiRu } from '../features/clickgui/i18n/ru' +import type { configsRu } from '../features/configs/i18n/ru' +import type { deviceLinkRu } from '../features/device-link/i18n/ru' import type { downloadRu } from '../features/download/i18n/ru' import type { landingRu } from '../features/landing/i18n/ru' +import type { profileRu } from '../features/profile/i18n/ru' +import type { showcaseRu } from '../features/showcase/i18n/ru' import type { themesRu } from '../features/themes/i18n/ru' import type { commonRu } from '../shared/i18n/common.ru' @@ -15,6 +20,11 @@ declare module 'i18next' { themes: typeof themesRu clickgui: typeof clickguiRu download: typeof downloadRu + deviceLink: typeof deviceLinkRu + account: typeof accountRu + configs: typeof configsRu + showcase: typeof showcaseRu + profile: typeof profileRu // Later tasks extend this with their own feature namespaces. } } diff --git a/frontend/src/app/routes.tsx b/frontend/src/app/routes.tsx index b07d4f3..f4b9651 100644 --- a/frontend/src/app/routes.tsx +++ b/frontend/src/app/routes.tsx @@ -4,10 +4,16 @@ import { AppShell } from '../shared/layout/AppShell' import HomePage from '../pages/public/HomePage' import LoginPage from '../pages/public/LoginPage' import RegisterPage from '../pages/public/RegisterPage' +import ProfilePage from '../pages/public/ProfilePage' import NotFoundPage from '../pages/public/NotFoundPage' // Code-split: the download page pulls in react-markdown, so it loads on demand. import DownloadRoute from '../pages/download/DownloadRoute' +import AccountPage from '../pages/app/AccountPage' +import LinkPage from '../pages/app/LinkPage' import ThemesRoute from '../pages/themes/ThemesRoute' +import ConfigsPage from '../pages/app/ConfigsPage' +import ShowcaseRoute from '../pages/showcase/ShowcaseRoute' +import ListingRoute from '../pages/showcase/ListingRoute' export const routes: RouteObject[] = [ { @@ -18,7 +24,17 @@ export const routes: RouteObject[] = [ { path: '/register', element: }, { path: '/download', element: }, { path: '/themes', element: }, - { element: , children: [] }, // Tasks 6–8 add /link, /account, /configs + { path: '/u/:id', element: }, + { path: '/showcase', element: }, + { path: '/showcase/:id', element: }, + { + element: , + children: [ + { path: '/link', element: }, + { path: '/account', element: }, + { path: '/configs', element: }, + ], + }, { path: '*', element: }, ], }, diff --git a/frontend/src/features/account/api.ts b/frontend/src/features/account/api.ts new file mode 100644 index 0000000..a3f9d47 --- /dev/null +++ b/frontend/src/features/account/api.ts @@ -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 { + return api.request('/device/links') +} + +export async function revokeDevice(id: string): Promise { + await api.request(`/device/links/${id}`, { method: 'DELETE' }) +} diff --git a/frontend/src/features/account/components/AvatarUpload.tsx b/frontend/src/features/account/components/AvatarUpload.tsx new file mode 100644 index 0000000..a86545e --- /dev/null +++ b/frontend/src/features/account/components/AvatarUpload.tsx @@ -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() + const mutation = useMutation({ + mutationFn: uploadAvatar, + onSuccess: () => client.invalidateQueries({ queryKey: ['me'] }), + }) + + function onChange(e: ChangeEvent) { + 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 ( +
+ + {error ? {error} : null} +
+ ) +} diff --git a/frontend/src/features/account/components/DeviceList.tsx b/frontend/src/features/account/components/DeviceList.tsx new file mode 100644 index 0000000..8a163ed --- /dev/null +++ b/frontend/src/features/account/components/DeviceList.tsx @@ -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 ( +
  • +
    +

    {t('devices.linkedAt', { date: relativeDate(device.linked_at) })}

    +

    + {t('devices.lastSeen', { when: device.last_seen ? relativeDate(device.last_seen) : t('devices.neverSeen') })} +

    +
    + {confirming ? ( +
    + + +
    + ) : ( + + )} +
  • + ) +} + +/** 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 ( +
    +

    {t('devices.heading')}

    + {devices.length === 0 ? ( +

    + {t('devices.empty')}{' '} + + {t('devices.emptyLink')} + +

    + ) : ( +
      + {devices.map((device) => ( + + ))} +
    + )} +
    + ) +} diff --git a/frontend/src/features/account/components/ProfileCard.tsx b/frontend/src/features/account/components/ProfileCard.tsx new file mode 100644 index 0000000..6725a66 --- /dev/null +++ b/frontend/src/features/account/components/ProfileCard.tsx @@ -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 ( +
    +
    +
    + {me.avatar_url ? ( + + ) : ( + + )} +
    +
    + +
    +

    {me.display_nick}

    +

    {me.email}

    +

    {t('profile.memberSince', { date: joined })}

    +
    + +
    + ) +} diff --git a/frontend/src/features/account/i18n/en.ts b/frontend/src/features/account/i18n/en.ts new file mode 100644 index 0000000..76249f1 --- /dev/null +++ b/frontend/src/features/account/i18n/en.ts @@ -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 diff --git a/frontend/src/features/account/i18n/ru.ts b/frontend/src/features/account/i18n/ru.ts new file mode 100644 index 0000000..05e775a --- /dev/null +++ b/frontend/src/features/account/i18n/ru.ts @@ -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 diff --git a/frontend/src/features/account/tests/account.test.tsx b/frontend/src/features/account/tests/account.test.tsx new file mode 100644 index 0000000..c8e88de --- /dev/null +++ b/frontend/src/features/account/tests/account.test.tsx @@ -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: }]) + 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() +}) diff --git a/frontend/src/features/auth/i18n/en.ts b/frontend/src/features/auth/i18n/en.ts index 2a34a25..e49d6e0 100644 --- a/frontend/src/features/auth/i18n/en.ts +++ b/frontend/src/features/auth/i18n/en.ts @@ -3,6 +3,7 @@ import type { authRu } from './ru' export const authEn = { checkingSession: 'Checking session…', + splitPitch: 'Your LoVisual setup, on any computer: cloud configs, share codes for friends, and a showcase of ready-made builds.', login: { title: 'Log in', emailLabel: 'Email', diff --git a/frontend/src/features/auth/i18n/ru.ts b/frontend/src/features/auth/i18n/ru.ts index 3dcebc5..672b50f 100644 --- a/frontend/src/features/auth/i18n/ru.ts +++ b/frontend/src/features/auth/i18n/ru.ts @@ -1,5 +1,6 @@ export const authRu = { checkingSession: 'Проверяем вход…', + splitPitch: 'Твои настройки LoVisual на любом компьютере: облачные конфиги, коды для друзей и витрина готовых сборок.', login: { title: 'Вход', emailLabel: 'Почта', diff --git a/frontend/src/features/clickgui/ClickGuiWindow.tsx b/frontend/src/features/clickgui/ClickGuiWindow.tsx index 135f445..12b9187 100644 --- a/frontend/src/features/clickgui/ClickGuiWindow.tsx +++ b/frontend/src/features/clickgui/ClickGuiWindow.tsx @@ -1,14 +1,16 @@ -import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { prefersReducedMotion } from '../../shared/motion/reducedMotion' -import { PRESETS } from '../themes/presets.generated' import type { ThemeEntry } from '../themes/types' import { useActiveTheme } from '../themes/useActiveTheme' import { DEMO, useDemo } from './demo' -import { ModuleCard } from './ModuleCard' import './parts/clickgui.css' -import { CategoryIcon, DemoCursor, LogoMark, SearchGlyph } from './parts/Glyphs' -import { CATEGORIES, QUICK_THEMES, TABS } from './parts/modules' +import { Backdrop } from './parts/Backdrop' +import { DemoCursor, Icon } from './parts/Glyphs' +import { QUICK_THEMES, TABS } from './parts/modules' +import { ModulesScreen } from './screens/ModulesScreen' +import './screens/screens.css' +import { SettingsScreen } from './screens/SettingsScreen' import { themeToCssVars } from './themeVars' type Props = { @@ -17,16 +19,43 @@ type Props = { paused?: boolean /** Overrides the active theme — for previews. */ theme?: ThemeEntry + /** + * Controlled theme picking: when set, theme cards in Settings call this instead of switching the + * site-wide theme (the /themes editor loads the preset into its draft). + */ + onPickTheme?: (id: string) => void + /** Image URL painted over the procedural backdrop (e.g. a real in-game F1 screenshot). */ + backdrop?: string className?: string } -export function ClickGuiWindow({ demo = true, paused = false, theme: override, className = '' }: Props) { +const HINTS = { + modules: ['toggle', 'settings', 'moduleList', 'search', 'close', 'hide'], + settings: ['pick', 'edit', 'wheel', 'close', 'hide'], +} as const + +function useClock(): string { + const format = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) + const [now, setNow] = useState(format) + useEffect(() => { + const id = window.setInterval(() => setNow(format()), 15_000) + return () => window.clearInterval(id) + }, []) + return now +} + +/** + * A faithful replica of the in-game ClickGui: the top pill bar (clock + Modules/Settings switch), + * the four glass category panels, and the Settings → Темы page whose cards re-theme everything. + */ +export function ClickGuiWindow({ demo = true, paused = false, theme: override, onPickTheme, backdrop, className = '' }: Props) { const { t } = useTranslation('clickgui') const { theme: active, setTheme } = useActiveTheme() const theme = override ?? active const rootRef = useRef(null) const [hovered, setHovered] = useState(false) const [reduced] = useState(prefersReducedMotion) + const clock = useClock() const themeIdRef = useRef(theme.id) useEffect(() => { themeIdRef.current = theme.id @@ -40,11 +69,14 @@ export function ClickGuiWindow({ demo = true, paused = false, theme: override, c const i = QUICK_THEMES.indexOf(themeIdRef.current as (typeof QUICK_THEMES)[number]) return QUICK_THEMES[(i + 1) % QUICK_THEMES.length] }, - onTheme: (id) => setTheme(id, 'demo'), + onTheme: (id) => (onPickTheme ? onPickTheme(id) : setTheme(id, 'demo')), }) - const vars = useMemo(() => themeToCssVars(theme), [theme]) - const category = CATEGORIES.find((c) => c.id === state.category) ?? CATEGORIES[0] + const vars = useMemo( + () => (backdrop ? { ...themeToCssVars(theme), '--gui-backdrop-image': `url(${JSON.stringify(backdrop)})` } : themeToCssVars(theme)), + [theme, backdrop], + ) + const pick = onPickTheme ?? ((id: string) => setTheme(id, 'user')) return (
    e.pointerType === 'mouse' && setHovered(true)} + onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)} > -