diff --git a/frontend/ARCHITECTURE.md b/frontend/ARCHITECTURE.md index 597082f..004213b 100644 --- a/frontend/ARCHITECTURE.md +++ b/frontend/ARCHITECTURE.md @@ -95,9 +95,10 @@ frontend/src/ shared/ ui/ Button.tsx - TextField.tsx - tests/ - ui.test.tsx + text-field/ + TextField.tsx + tests/ + text-field.test.tsx ``` Источники: [Robin Wieruch — React Folder Structure Best Practices 2026](https://www.robinwieruch.de/react-folder-structure/), [Mastering Modern React + Vite Folder Structure, Medium](https://sandeshrathnayake.medium.com/mastering-modern-react-vite-folder-structure-a-production-ready-guide-for-scalable-applications-9ad8e233f8b9). diff --git a/frontend/src/app/routes.tsx b/frontend/src/app/routes.tsx index f4b9651..5c2903c 100644 --- a/frontend/src/app/routes.tsx +++ b/frontend/src/app/routes.tsx @@ -2,8 +2,8 @@ import type { RouteObject } from 'react-router' import { RequireAuth } from '../features/auth/RequireAuth' 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 LoginPage from '../pages/public/auth/LoginPage' +import RegisterPage from '../pages/public/auth/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. diff --git a/frontend/src/features/auth/forms/LoginForm.tsx b/frontend/src/features/auth/forms/LoginForm.tsx index e37b590..5c5353b 100644 --- a/frontend/src/features/auth/forms/LoginForm.tsx +++ b/frontend/src/features/auth/forms/LoginForm.tsx @@ -4,7 +4,7 @@ import { Link, useLocation, useNavigate } from 'react-router' import { describeError } from '../../../shared/api/errors' import { Button } from '../../../shared/ui/Button' import { Notice } from '../../../shared/ui/Notice' -import { TextField } from '../../../shared/ui/TextField' +import { TextField } from '../../../shared/ui/text-field/TextField' import { useSession } from '../session' export function LoginForm() { diff --git a/frontend/src/features/auth/forms/RegisterForm.tsx b/frontend/src/features/auth/forms/RegisterForm.tsx index 0b186cc..d393c47 100644 --- a/frontend/src/features/auth/forms/RegisterForm.tsx +++ b/frontend/src/features/auth/forms/RegisterForm.tsx @@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router' import { describeError } from '../../../shared/api/errors' import { Button } from '../../../shared/ui/Button' import { Notice } from '../../../shared/ui/Notice' -import { TextField } from '../../../shared/ui/TextField' +import { TextField } from '../../../shared/ui/text-field/TextField' import { useSession } from '../session' import { validateEmail, validateNick, validatePassword } from '../validation' diff --git a/frontend/src/features/configs/components/PublishForm.tsx b/frontend/src/features/configs/components/PublishForm.tsx index 3e01832..7a6ea07 100644 --- a/frontend/src/features/configs/components/PublishForm.tsx +++ b/frontend/src/features/configs/components/PublishForm.tsx @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '../../../shared/ui/Button' -import { TextField } from '../../../shared/ui/TextField' +import { TextField } from '../../../shared/ui/text-field/TextField' import { publish } from '../api' const MAX_DESCRIPTION = 500 diff --git a/frontend/src/features/configs/components/SlotPanel.tsx b/frontend/src/features/configs/components/SlotPanel.tsx index 0a13f37..2370e2e 100644 --- a/frontend/src/features/configs/components/SlotPanel.tsx +++ b/frontend/src/features/configs/components/SlotPanel.tsx @@ -3,7 +3,7 @@ import { useId, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '../../../shared/ui/Button' import { Notice } from '../../../shared/ui/Notice' -import { ShareCode } from '../../../shared/ui/ShareCode' +import { ShareCode } from '../../../shared/ui/share-code/ShareCode' import { regenerateCode, unpublish } from '../api' import type { SlotSummary } from '../types' import { PublishForm } from './PublishForm' diff --git a/frontend/src/pages/app/ConfigsPage.tsx b/frontend/src/pages/app/ConfigsPage.tsx index 9d8d89a..d04448e 100644 --- a/frontend/src/pages/app/ConfigsPage.tsx +++ b/frontend/src/pages/app/ConfigsPage.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import { ParticleField } from '../../features/landing/effects/ParticleField' import { VoxelScene } from '../../features/landing/effects/VoxelScene' import { SlotGrid } from '../../features/configs/components/SlotGrid' -import { PageTitle } from '../../shared/ui/PageTitle' +import { PageTitle } from '../../shared/layout/PageTitle' const ACCENT = 'rgb(92, 200, 231)' diff --git a/frontend/src/pages/app/LinkPage.tsx b/frontend/src/pages/app/LinkPage.tsx index b38145c..d6d64db 100644 --- a/frontend/src/pages/app/LinkPage.tsx +++ b/frontend/src/pages/app/LinkPage.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { LinkDeviceForm } from '../../features/device-link/LinkDeviceForm' -import { PageTitle } from '../../shared/ui/PageTitle' +import { PageTitle } from '../../shared/layout/PageTitle' const STEPS = ['one', 'two', 'three'] as const diff --git a/frontend/src/pages/download/DownloadPage.tsx b/frontend/src/pages/download/DownloadPage.tsx index fb5fb25..5d3743d 100644 --- a/frontend/src/pages/download/DownloadPage.tsx +++ b/frontend/src/pages/download/DownloadPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { DownloadButton } from '../../features/download/DownloadButton' import { ReleaseNotes } from '../../features/download/ReleaseNotes' import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api' -import { PageTitle } from '../../shared/ui/PageTitle' +import { PageTitle } from '../../shared/layout/PageTitle' // Requirement versions mirror mod/gradle.properties (the mod's build source of truth). const REQUIREMENTS = [ diff --git a/frontend/src/pages/public/NotFoundPage.tsx b/frontend/src/pages/public/NotFoundPage.tsx index fe2cfbb..d27b02b 100644 --- a/frontend/src/pages/public/NotFoundPage.tsx +++ b/frontend/src/pages/public/NotFoundPage.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import { Link } from 'react-router' import { NOT_FOUND_ART } from '../../shared/easter/art.generated' -import { PageTitle } from '../../shared/ui/PageTitle' +import { PageTitle } from '../../shared/layout/PageTitle' export default function NotFoundPage() { const { t } = useTranslation('common') diff --git a/frontend/src/pages/public/LoginPage.tsx b/frontend/src/pages/public/auth/LoginPage.tsx similarity index 66% rename from frontend/src/pages/public/LoginPage.tsx rename to frontend/src/pages/public/auth/LoginPage.tsx index 5afe445..d2623d4 100644 --- a/frontend/src/pages/public/LoginPage.tsx +++ b/frontend/src/pages/public/auth/LoginPage.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' -import { LoginForm } from '../../features/auth/forms/LoginForm' -import { AuthSplit } from './auth/AuthSplit' +import { LoginForm } from '../../../features/auth/forms/LoginForm' +import { AuthSplit } from './AuthSplit' export default function LoginPage() { const { t } = useTranslation('auth') diff --git a/frontend/src/pages/public/RegisterPage.tsx b/frontend/src/pages/public/auth/RegisterPage.tsx similarity index 72% rename from frontend/src/pages/public/RegisterPage.tsx rename to frontend/src/pages/public/auth/RegisterPage.tsx index 49811bf..57c4400 100644 --- a/frontend/src/pages/public/RegisterPage.tsx +++ b/frontend/src/pages/public/auth/RegisterPage.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' -import { RegisterForm } from '../../features/auth/forms/RegisterForm' -import { AuthSplit } from './auth/AuthSplit' +import { RegisterForm } from '../../../features/auth/forms/RegisterForm' +import { AuthSplit } from './AuthSplit' export default function RegisterPage() { const { t } = useTranslation('auth') diff --git a/frontend/src/pages/showcase/ShowcasePage.tsx b/frontend/src/pages/showcase/ShowcasePage.tsx index 69adb08..2f961ce 100644 --- a/frontend/src/pages/showcase/ShowcasePage.tsx +++ b/frontend/src/pages/showcase/ShowcasePage.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import { ParticleField } from '../../features/landing/effects/ParticleField' import { VoxelScene } from '../../features/landing/effects/VoxelScene' import { ListingList } from '../../features/showcase/components/ListingList' -import { PageTitle } from '../../shared/ui/PageTitle' +import { PageTitle } from '../../shared/layout/PageTitle' const ACCENT = 'rgb(92, 200, 231)' diff --git a/frontend/src/pages/themes/ThemesPage.tsx b/frontend/src/pages/themes/ThemesPage.tsx index 8aac99d..e912990 100644 --- a/frontend/src/pages/themes/ThemesPage.tsx +++ b/frontend/src/pages/themes/ThemesPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { ThemeEditor } from '../../features/themes/editor/ThemeEditor' import { fromShareHash } from '../../features/themes/editor/model/codec' import { PRESETS } from '../../features/themes/presets.generated' -import { PageTitle } from '../../shared/ui/PageTitle' +import { PageTitle } from '../../shared/layout/PageTitle' import '../../features/themes/editor/editor.css' /** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */ diff --git a/frontend/src/shared/ui/PageTitle.tsx b/frontend/src/shared/layout/PageTitle.tsx similarity index 100% rename from frontend/src/shared/ui/PageTitle.tsx rename to frontend/src/shared/layout/PageTitle.tsx diff --git a/frontend/src/shared/layout/tests/layout.test.tsx b/frontend/src/shared/layout/tests/layout.test.tsx index 329ef7a..99de0a9 100644 --- a/frontend/src/shared/layout/tests/layout.test.tsx +++ b/frontend/src/shared/layout/tests/layout.test.tsx @@ -1,10 +1,11 @@ -import { screen, within } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { getI18n } from 'react-i18next' import { afterEach, expect, test } from 'vitest' import { routes } from '../../../app/routes' import { renderApp } from '../../../test/render' import { json, mockFetch } from '../../../test/fetch' +import { PageTitle } from '../PageTitle' afterEach(async () => { await getI18n().changeLanguage('ru') @@ -103,3 +104,11 @@ test('footer carries the github link and the disclaimer', async () => { const gh = screen.getByRole('link', { name: 'GitHub' }) expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//) }) + +test('page title renders one h1 with an optional lead', () => { + render( + Мои конфиги, + ) + expect(screen.getByRole('heading', { level: 1, name: 'Мои конфиги' })).toBeInTheDocument() + expect(screen.getByText('Короткое описание')).toBeInTheDocument() +}) diff --git a/frontend/src/shared/ui/ShareCode.tsx b/frontend/src/shared/ui/share-code/ShareCode.tsx similarity index 96% rename from frontend/src/shared/ui/ShareCode.tsx rename to frontend/src/shared/ui/share-code/ShareCode.tsx index 2fc1351..d492863 100644 --- a/frontend/src/shared/ui/ShareCode.tsx +++ b/frontend/src/shared/ui/share-code/ShareCode.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from './Button' +import { Button } from '../Button' export function ShareCode({ code, label }: { code: string; label?: string }) { const { t } = useTranslation('common') diff --git a/frontend/src/shared/ui/share-code/tests/share-code.test.tsx b/frontend/src/shared/ui/share-code/tests/share-code.test.tsx new file mode 100644 index 0000000..5c576ba --- /dev/null +++ b/frontend/src/shared/ui/share-code/tests/share-code.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { expect, test, vi } from 'vitest' +import { ShareCode } from '../ShareCode' + +test('share code copies to clipboard and confirms', async () => { + const user = userEvent.setup() + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + render() + expect(screen.getByText('7KQ3M9XA')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Скопировать' })) + expect(writeText).toHaveBeenCalledWith('7KQ3M9XA') + expect(await screen.findByText('Скопировано')).toBeInTheDocument() +}) diff --git a/frontend/src/shared/ui/tests/ui.test.tsx b/frontend/src/shared/ui/tests/ui.test.tsx deleted file mode 100644 index 1ccff6e..0000000 --- a/frontend/src/shared/ui/tests/ui.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { expect, test, vi } from 'vitest' -import { Button } from '../Button' -import { PageTitle } from '../PageTitle' -import { TextField } from '../TextField' -import { ShareCode } from '../ShareCode' -import { Notice } from '../Notice' - -test('busy button is disabled and announces busy', () => { - render() - const b = screen.getByRole('button', { name: 'Сохранить' }) - expect(b).toBeDisabled() - expect(b).toHaveAttribute('aria-busy', 'true') -}) - -test('text field links label and error', () => { - render() - const input = screen.getByLabelText('Почта') - expect(input).toHaveAttribute('aria-invalid', 'true') - expect(input).toHaveAccessibleDescription('Введи почту') -}) - -test('share code copies to clipboard and confirms', async () => { - const user = userEvent.setup() - const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() - render() - expect(screen.getByText('7KQ3M9XA')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Скопировать' })) - expect(writeText).toHaveBeenCalledWith('7KQ3M9XA') - expect(await screen.findByText('Скопировано')).toBeInTheDocument() -}) - -test('error notice is an alert', () => { - render(Неверный код) - expect(screen.getByRole('alert')).toHaveTextContent('Неверный код') -}) - -test('page title renders one h1 with an optional lead', () => { - render( - Мои конфиги, - ) - expect(screen.getByRole('heading', { level: 1, name: 'Мои конфиги' })).toBeInTheDocument() - expect(screen.getByText('Короткое описание')).toBeInTheDocument() -}) diff --git a/frontend/src/shared/ui/TextField.tsx b/frontend/src/shared/ui/text-field/TextField.tsx similarity index 100% rename from frontend/src/shared/ui/TextField.tsx rename to frontend/src/shared/ui/text-field/TextField.tsx diff --git a/frontend/src/shared/ui/text-field/tests/text-field.test.tsx b/frontend/src/shared/ui/text-field/tests/text-field.test.tsx new file mode 100644 index 0000000..9e69b9a --- /dev/null +++ b/frontend/src/shared/ui/text-field/tests/text-field.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react' +import { expect, test } from 'vitest' +import { Button } from '../../Button' +import { Notice } from '../../Notice' +import { TextField } from '../TextField' + +test('busy button is disabled and announces busy', () => { + render() + const b = screen.getByRole('button', { name: 'Сохранить' }) + expect(b).toBeDisabled() + expect(b).toHaveAttribute('aria-busy', 'true') +}) + +test('text field links label and error', () => { + render() + const input = screen.getByLabelText('Почта') + expect(input).toHaveAttribute('aria-invalid', 'true') + expect(input).toHaveAccessibleDescription('Введи почту') +}) + +test('error notice is an alert', () => { + render(Неверный код) + expect(screen.getByRole('alert')).toHaveTextContent('Неверный код') +})