feat: establish frontend foundation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
@@ -0,0 +1,25 @@
|
||||
# Frontend
|
||||
|
||||
## Назначение
|
||||
|
||||
React/Vite-клиент WealthLedger. Функциональные изменения сначала планируются в `../specs/openspec`; контракт API задает backend OpenAPI, а не ручные DTO.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test:run
|
||||
npm run build
|
||||
npm run check:no-todo
|
||||
```
|
||||
|
||||
`npm run openapi:generate` получает документ работающего backend по `${VITE_API_BASE_URL:-http://localhost:3000}/api/docs-json`; фактическая генерация выполняется только в задаче 5.1. `build` выполняет `tsc -b`.
|
||||
|
||||
## Границы
|
||||
|
||||
- Текущий baseline использует FSD, React Router, i18n, Mantine, React Query и typed API scaffold.
|
||||
- Зависимости FSD идут `app -> pages -> features -> entities -> shared`; слайсы импортируются только через публичный `index.ts`.
|
||||
- HTTP идет через типизированный клиент, сгенерированный из OpenAPI; UI-строки - только через i18n. Каждый маршрут имеет loading, error и empty состояния.
|
||||
- Соблюдать strict TypeScript: `import type` для типов, без `enum` и `namespace`.
|
||||
- Не добавлять прямые вызовы T-Bank и не переносить Todo-код из `../references/`.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!dist",
|
||||
"!src/shared/api/generated",
|
||||
"!coverage",
|
||||
"!node_modules"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"preset": "recommended"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
Generated
+1947
-1048
File diff suppressed because it is too large
Load Diff
+43
-32
@@ -1,34 +1,45 @@
|
||||
{
|
||||
"name": "wealth-ledger-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.2.0"
|
||||
}
|
||||
"name": "wealth-ledger-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "biome check .",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"check:no-todo": "! rg -i todo src --glob '*.{ts,tsx}'",
|
||||
"openapi:generate": "openapi-typescript \"${VITE_API_BASE_URL:-http://localhost:3000}/api/docs-json\" -o src/shared/api/generated/schema.d.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.3.14",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"i18next": "^26.0.2",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-router": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@biomejs/biome": "2.5.8",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.0.16",
|
||||
"jsdom": "^27.4.0",
|
||||
"openapi-typescript": "^7.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
+1
-1
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"><title>Icon sprite</title>
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
-184
@@ -1,184 +0,0 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1 @@
|
||||
export { App } from "./ui/App";
|
||||
@@ -0,0 +1,81 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AccountsPageEmpty,
|
||||
AccountsPageError,
|
||||
AccountsPageLoading,
|
||||
} from "../../pages/accounts";
|
||||
import { theme } from "../theme";
|
||||
import { AppRoutes } from "./AppRoutes";
|
||||
|
||||
describe("AppRoutes", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it.each([
|
||||
["/", "Счета"],
|
||||
["/login", "Вход"],
|
||||
["/register", "Регистрация"],
|
||||
["/accounts", "Счета"],
|
||||
["/accounts/demo", "Счет"],
|
||||
["/settings", "Настройки"],
|
||||
["/unknown", "Страница не найдена"],
|
||||
])("renders %s with its page title", (path, title) => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<MantineProvider theme={theme}>
|
||||
<AppRoutes />
|
||||
</MantineProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: title }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the empty state for accounts by default", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/accounts"]}>
|
||||
<MantineProvider theme={theme}>
|
||||
<AppRoutes />
|
||||
</MantineProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Пока ничего нет" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Загрузка")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Что-то пошло не так")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loading", AccountsPageLoading, "status"],
|
||||
["error", AccountsPageError, "alert"],
|
||||
["empty", AccountsPageEmpty, undefined],
|
||||
] as const)("renders only the Accounts %s state", (_name, State, role) => {
|
||||
render(
|
||||
<MantineProvider theme={theme}>
|
||||
<State />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
if (role) {
|
||||
expect(screen.getByRole(role)).toBeInTheDocument();
|
||||
} else {
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Пока ничего нет" }),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
|
||||
expect(screen.queryAllByRole("status")).toHaveLength(
|
||||
role === "status" ? 1 : 0,
|
||||
);
|
||||
expect(screen.queryAllByRole("alert")).toHaveLength(
|
||||
role === "alert" ? 1 : 0,
|
||||
);
|
||||
expect(screen.queryAllByText("Пока ничего нет")).toHaveLength(role ? 0 : 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
|
||||
import { AccountPage } from "../../pages/account";
|
||||
import { AccountsPage } from "../../pages/accounts";
|
||||
import { LoginPage } from "../../pages/login";
|
||||
import { NotFoundPage } from "../../pages/not-found";
|
||||
import { RegisterPage } from "../../pages/register";
|
||||
import { SettingsPage } from "../../pages/settings";
|
||||
import { AppShell } from "../../shared/ui/AppShell";
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/accounts/:accountId" element={<AccountPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AppRoutes } from "./AppRoutes";
|
||||
@@ -0,0 +1,55 @@
|
||||
:root {
|
||||
--ft-color-bg-page: #e8ecf1;
|
||||
--ft-color-bg-app: #f5f7fa;
|
||||
--ft-color-bg-surface: #ffffff;
|
||||
--ft-color-bg-muted: #f1f5f9;
|
||||
--ft-color-text-primary: #1e293b;
|
||||
--ft-color-text-secondary: #64748b;
|
||||
--ft-color-border: #e2e8f0;
|
||||
--ft-color-accent: #2563eb;
|
||||
--ft-color-danger: #ef4444;
|
||||
--ft-radius-card: 12px;
|
||||
--ft-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ft-color-bg-page);
|
||||
color: var(--ft-color-text-primary);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.app-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 47.99375em) {
|
||||
.bottom-nav {
|
||||
background: var(--ft-color-bg-surface);
|
||||
border-top: 1px solid var(--ft-color-border);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
left: 0;
|
||||
padding: 0.75rem;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
.bottom-nav .active {
|
||||
color: var(--ft-color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mantine-AppShell-main {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createTheme } from "@mantine/core";
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: "blue",
|
||||
defaultRadius: "md",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
colors: {
|
||||
blue: [
|
||||
"#eff6ff",
|
||||
"#dbeafe",
|
||||
"#bfdbfe",
|
||||
"#93c5fd",
|
||||
"#60a5fa",
|
||||
"#3b82f6",
|
||||
"#2563eb",
|
||||
"#1d4ed8",
|
||||
"#1e40af",
|
||||
"#1e3a8a",
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router";
|
||||
|
||||
import { AppRoutes } from "../routes";
|
||||
import { theme } from "../theme";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<MantineProvider theme={theme} forceColorScheme="light">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
+17
-9
@@ -1,10 +1,18 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@mantine/core/styles.css";
|
||||
import "./app/styles.css";
|
||||
import "./shared/i18n";
|
||||
import { App } from "./app";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
AccountPage,
|
||||
AccountPageEmpty,
|
||||
AccountPageError,
|
||||
AccountPageLoading,
|
||||
} from "./ui/AccountPage";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
|
||||
export function AccountPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("account.title")}</Title>
|
||||
<AccountPageEmpty />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
AccountsPage,
|
||||
AccountsPageEmpty,
|
||||
AccountsPageError,
|
||||
AccountsPageLoading,
|
||||
} from "./ui/AccountsPage";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
|
||||
export function AccountsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("accounts.title")}</Title>
|
||||
<AccountsPageEmpty />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountsPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountsPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountsPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
LoginPage,
|
||||
LoginPageEmpty,
|
||||
LoginPageError,
|
||||
LoginPageLoading,
|
||||
} from "./ui/LoginPage";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("auth.login")}</Title>
|
||||
<LoginPageEmpty />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
NotFoundPage,
|
||||
NotFoundPageEmpty,
|
||||
NotFoundPageError,
|
||||
NotFoundPageLoading,
|
||||
} from "./ui/NotFoundPage";
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { Button, StateView } from "../../../shared/ui/StateView";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("notFound.title")}</Title>
|
||||
<NotFoundPageEmpty />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotFoundPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotFoundPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotFoundPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
title={t("states.emptyTitle")}
|
||||
text={t("states.emptyText")}
|
||||
action={
|
||||
<Button component={Link} to="/accounts">
|
||||
{t("notFound.action")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
RegisterPage,
|
||||
RegisterPageEmpty,
|
||||
RegisterPageError,
|
||||
RegisterPageLoading,
|
||||
} from "./ui/RegisterPage";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
|
||||
export function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("auth.register")}</Title>
|
||||
<RegisterPageEmpty />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
SettingsPage,
|
||||
SettingsPageEmpty,
|
||||
SettingsPageError,
|
||||
SettingsPageLoading,
|
||||
} from "./ui/SettingsPage";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "../../../shared/ui/StateView";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("settings.title")}</Title>
|
||||
<SettingsPageEmpty />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
loading
|
||||
title={t("states.loadingTitle")}
|
||||
text={t("states.loadingText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPageError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPageEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import createClient from "openapi-fetch";
|
||||
|
||||
import { apiBaseUrl } from "./config";
|
||||
import type { paths } from "./generated/schema";
|
||||
|
||||
export const apiClient = createClient<paths>({ baseUrl: apiBaseUrl });
|
||||
@@ -0,0 +1,2 @@
|
||||
export const apiBaseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
|
||||
Vendored
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["AppController_getHello"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
SafeErrorResponseDto: {
|
||||
/** @example 500 */
|
||||
statusCode: number;
|
||||
/** @example Internal server error */
|
||||
message: string;
|
||||
/** @example /api/v1 */
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
AppController_getHello: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": string;
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SafeErrorResponseDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { apiClient } from "./client";
|
||||
export { apiBaseUrl } from "./config";
|
||||
@@ -0,0 +1,13 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import { ru } from "./locales/ru";
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: { ru },
|
||||
lng: "ru",
|
||||
fallbackLng: "ru",
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export { i18n };
|
||||
@@ -0,0 +1,19 @@
|
||||
export const ru = {
|
||||
translation: {
|
||||
appName: "WealthLedger",
|
||||
navigation: { accounts: "Счета", settings: "Настройки" },
|
||||
auth: { login: "Вход", register: "Регистрация" },
|
||||
accounts: { title: "Счета" },
|
||||
account: { title: "Счет" },
|
||||
settings: { title: "Настройки" },
|
||||
notFound: { title: "Страница не найдена", action: "К счетам" },
|
||||
states: {
|
||||
loadingTitle: "Загрузка",
|
||||
loadingText: "Пожалуйста, подождите.",
|
||||
errorTitle: "Что-то пошло не так",
|
||||
errorText: "Попробуйте обновить страницу позже.",
|
||||
emptyTitle: "Пока ничего нет",
|
||||
emptyText: "Данные появятся здесь.",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
|
||||
|
||||
export function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const { pathname } = useLocation();
|
||||
const items = [
|
||||
{ to: "/accounts", label: t("navigation.accounts") },
|
||||
{ to: "/settings", label: t("navigation.settings") },
|
||||
];
|
||||
|
||||
return (
|
||||
<MantineAppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{ width: 220, breakpoint: "sm", collapsed: { mobile: true } }}
|
||||
padding="md"
|
||||
>
|
||||
<MantineAppShell.Header className="app-header">
|
||||
<Text fw={700}>{t("appName")}</Text>
|
||||
</MantineAppShell.Header>
|
||||
<MantineAppShell.Navbar p="sm">
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
component={RouterNavLink}
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
label={item.label}
|
||||
active={pathname.startsWith(item.to)}
|
||||
/>
|
||||
))}
|
||||
</MantineAppShell.Navbar>
|
||||
<MantineAppShell.Main>
|
||||
<Outlet />
|
||||
</MantineAppShell.Main>
|
||||
<nav className="bottom-nav" aria-label={t("appName")}>
|
||||
{items.map((item) => (
|
||||
<RouterNavLink
|
||||
className={pathname.startsWith(item.to) ? "active" : ""}
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
>
|
||||
{item.label}
|
||||
</RouterNavLink>
|
||||
))}
|
||||
</nav>
|
||||
</MantineAppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { theme } from "../../app/theme";
|
||||
import { StateView } from "./StateView";
|
||||
|
||||
describe("StateView", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders an accessible loading state with a loader", () => {
|
||||
render(
|
||||
<MantineProvider theme={theme}>
|
||||
<StateView loading text="Пожалуйста, подождите." title="Загрузка" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveTextContent("Загрузка");
|
||||
expect(screen.getByLabelText("Загрузка")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an accessible error state", () => {
|
||||
render(
|
||||
<MantineProvider theme={theme}>
|
||||
<StateView
|
||||
error
|
||||
text="Попробуйте обновить страницу позже."
|
||||
title="Что-то пошло не так"
|
||||
/>
|
||||
,
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Что-то пошло не так");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Button, Loader, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type StateViewProps = {
|
||||
title: string;
|
||||
text: string;
|
||||
action?: ReactNode;
|
||||
loading?: boolean;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
export function StateView({
|
||||
title,
|
||||
text,
|
||||
action,
|
||||
loading = false,
|
||||
error = false,
|
||||
}: StateViewProps) {
|
||||
const role = loading ? "status" : error ? "alert" : undefined;
|
||||
|
||||
return (
|
||||
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}>
|
||||
<Stack align="flex-start" gap="sm">
|
||||
{loading ? <Loader aria-label={title} size="sm" /> : null}
|
||||
<Title order={2}>{title}</Title>
|
||||
<Text c="dimmed">{text}</Text>
|
||||
{action}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button };
|
||||
@@ -0,0 +1,16 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "../shared/i18n";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
+22
-24
@@ -1,28 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json"},
|
||||
{ "path": "./tsconfig.node.json"}
|
||||
]
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
+19
-21
@@ -1,25 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
+5
-8
@@ -1,11 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
import babel from "@rolldown/plugin-babel";
|
||||
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
],
|
||||
})
|
||||
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user