feat: establish frontend foundation

This commit is contained in:
2026-08-12 23:04:16 +03:00
parent 3331b7f684
commit 1e13adabd3
46 changed files with 2868 additions and 1588 deletions
+6
View File
@@ -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 });
+2
View File
@@ -0,0 +1,2 @@
export const apiBaseUrl =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
+72
View File
@@ -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"];
};
};
};
};
}
+2
View File
@@ -0,0 +1,2 @@
export { apiClient } from "./client";
export { apiBaseUrl } from "./config";
+13
View File
@@ -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 };
+19
View File
@@ -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;
+49
View File
@@ -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>
);
}
+36
View File
@@ -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("Что-то пошло не так");
});
});
+33
View File
@@ -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 };