feat(web): оболочка приложения — маршруты /app, разделы, ошибки страниц, тема (#21)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 20:14:27 +04:00
parent c124e082ae
commit 6dc0543a50
16 changed files with 278 additions and 7 deletions

View File

@ -1,8 +1,56 @@
import { render, screen } from "@testing-library/react";
import { expect, it } from "vitest";
import { screen } from "@testing-library/react";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { App } from "./App.tsx";
import { clearToken, getAuth, setToken } from "./auth/token.ts";
import { routes } from "./routes.tsx";
import { json, mockFetch } from "./test/fetchMock.ts";
it("показывает заголовок", () => {
render(<App />);
expect(screen.getByRole("heading", { name: "Хаб Jira" })).toBeInTheDocument();
const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
describe("оболочка", () => {
beforeEach(() => { setToken("tok"); mockFetch(() => json(200, {})); });
it("/app ведёт на доску", async () => {
render(<App router={at("/app")} />);
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
});
it("переход «Задачи» и обратно", async () => {
const router = at("/app/board");
render(<App router={router} />);
await userEvent.click(screen.getByRole("link", { name: "Задачи" }));
expect(router.state.location.pathname).toBe("/app/issues");
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
});
it("неизвестный путь — «не найдено» со ссылками", async () => {
render(<App router={at("/app/nope")} />);
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
});
it("«Сменить токен» показывает экран входа", async () => {
render(<App router={at("/app/board")} />);
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
expect(getAuth().token).toBe("");
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
});
it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
const Boom = () => { throw new Error("сломалось"); };
const router = createMemoryRouter([{ path: "/", element: <App.Layout />, children: [{ path: "x", element: <Boom /> }] }],
{ initialEntries: ["/x"] });
render(<App router={router} />);
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
});
it("без токена — только экран входа", () => {
clearToken();
render(<App router={at("/app/board")} />);
expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument();
});
});

View File

@ -1,3 +1,23 @@
export function App() {
return <h1>Хаб Jira</h1>;
import { QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createBrowserRouter, RouterProvider } from "react-router";
import { makeQueryClient } from "./api/queryClient.ts";
import { AuthGate } from "./auth/AuthGate.tsx";
import { routes } from "./routes.tsx";
import { Layout } from "./shell/Layout.tsx";
import "./styles/theme.css";
import "./styles/base.css";
import "./styles/controls.css";
type Router = ReturnType<typeof createBrowserRouter>;
export function App({ router }: { router?: Router }) {
const [client] = useState(makeQueryClient);
const [r] = useState(() => router ?? createBrowserRouter(routes, { basename: "/app" }));
return (
<QueryClientProvider client={client}>
<AuthGate><RouterProvider router={r} /></AuthGate>
</QueryClientProvider>
);
}
App.Layout = Layout;

View File

@ -0,0 +1,2 @@
// временная страница (заменяется в Task 11)
export function BoardPage() { return <div data-testid="page-board" />; }

View File

@ -0,0 +1,2 @@
// временная страница (заменяется в Task 14)
export function IssuePage() { return <div data-testid="page-issue" />; }

View File

@ -0,0 +1,2 @@
// временная страница (заменяется в Task 13)
export function IssuesPage() { return <div data-testid="page-issues" />; }

View File

@ -1,6 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.tsx";
import { applyTheme, readTheme } from "./shell/theme.ts";
applyTheme(readTheme());
createRoot(document.getElementById("root")!).render(
<StrictMode>

19
web/src/routes.tsx Normal file
View File

@ -0,0 +1,19 @@
import { Navigate, type RouteObject } from "react-router";
import { BoardPage } from "./board/BoardPage.tsx";
import { IssuePage } from "./issues/IssuePage.tsx";
import { IssuesPage } from "./issues/IssuesPage.tsx";
import { Layout } from "./shell/Layout.tsx";
import { NotFound } from "./shell/NotFound.tsx";
// адреса относительно basename "/app": /app → /app/board
export const routes: RouteObject[] = [{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Navigate to="/board" replace /> },
{ path: "board", element: <BoardPage /> },
{ path: "issues", element: <IssuesPage /> },
{ path: "issues/:key", element: <IssuePage /> },
{ path: "*", element: <NotFound /> },
],
}];

View File

@ -0,0 +1,20 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface State { error: Error | null }
// неожиданная ошибка отрисовки страницы: сообщение и перезагрузка вместо пустого экрана; панель разделов остаётся
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State { return { error }; }
componentDidCatch(error: Error, info: ErrorInfo) { console.error("ошибка страницы", error, info.componentStack); }
render() {
if (!this.state.error) return this.props.children;
return (
<div className="crash" role="alert">
<h2>Страница сломалась</h2>
<p>{this.state.error.message}</p>
<button className="btn" type="button" onClick={() => window.location.reload()}>Перезагрузить</button>
</div>
);
}
}

26
web/src/shell/Layout.tsx Normal file
View File

@ -0,0 +1,26 @@
import { useQueryClient } from "@tanstack/react-query";
import { NavLink, Outlet, useLocation } from "react-router";
import { clearToken } from "../auth/token.ts";
import { ErrorBoundary } from "./ErrorBoundary.tsx";
import "./shell.css";
export function Layout() {
const qc = useQueryClient();
const { pathname } = useLocation();
return (
<>
<header className="appbar">
<span className="brand">Хаб Jira</span>
<nav>
<NavLink to="/board">Доска</NavLink>
<NavLink to="/issues" end={false}>Задачи</NavLink>
</nav>
<span className="spacer" />
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
</header>
<main className="page">
<ErrorBoundary key={pathname}><Outlet /></ErrorBoundary>
</main>
</>
);
}

View File

@ -0,0 +1,10 @@
import { Link } from "react-router";
export function NotFound() {
return (
<div className="crash">
<h2>Страница не найдена</h2>
<p>Разделы приложения: <Link to="/board">доска</Link>, <Link to="/issues">задачи</Link>.</p>
</div>
);
}

8
web/src/shell/shell.css Normal file
View File

@ -0,0 +1,8 @@
/* оболочка: верхняя панель разделов */
.appbar{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
.appbar .brand{font-weight:600}
.appbar nav{display:flex;gap:4px}
.appbar nav a{color:var(--fg);text-decoration:none;padding:6px 10px;border-radius:6px}
.appbar nav a.active{background:var(--accent);color:var(--accent-fg)}
.appbar .spacer{flex:1 1 auto}
.crash{max-width:640px;margin:48px auto}

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { applyTheme, readTheme, saveTheme } from "./theme.ts";
describe("тема", () => {
it("читает graph-theme, неизвестное значение — системная", () => {
localStorage.setItem("graph-theme", "dark");
expect(readTheme()).toBe("dark");
localStorage.setItem("graph-theme", "purple");
expect(readTheme()).toBe("");
});
it("applyTheme ставит и снимает data-theme, saveTheme пишет ключ", () => {
applyTheme("light");
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
applyTheme("");
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
saveTheme("dark");
expect(localStorage.getItem("graph-theme")).toBe("dark");
});
});

20
web/src/shell/theme.ts Normal file
View File

@ -0,0 +1,20 @@
// тема интерфейса: "" — системная, light, dark; ключ graph-theme, как у прежней доски (links.html, строки 1000–1003)
export const THEMES = ["", "light", "dark"] as const;
export type Theme = (typeof THEMES)[number];
export const THEME_NAMES: Record<Theme, string> = { "": "системная", light: "светлая", dark: "тёмная" };
const KEY = "graph-theme";
export function readTheme(): Theme {
let v: string | null = null;
try { v = localStorage.getItem(KEY); } catch { /* закрытое хранилище */ }
return (THEMES as readonly string[]).includes(v ?? "") ? (v as Theme) : "";
}
export function saveTheme(t: Theme): void {
try { localStorage.setItem(KEY, t); } catch { /* закрытое хранилище */ }
}
export function applyTheme(t: Theme): void {
if (t) document.documentElement.setAttribute("data-theme", t);
else document.documentElement.removeAttribute("data-theme");
}

11
web/src/styles/base.css Normal file
View File

@ -0,0 +1,11 @@
/* 2. Основа */
html,body{min-height:100%}
body{background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:15px;line-height:1.45;margin:0}
h1{font-size:20px;font-weight:600;margin:0}
[hidden]{display:none!important}
.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.spacer{flex:1 1 auto}
.hint{font-size:13px;color:var(--muted)}
.warn{color:var(--p-high)}
.page{padding-inline:16px;padding-block:0 32px}
.login{max-width:560px;margin:48px auto}.login form{display:flex;gap:8px}.login input{flex:1;font:inherit;height:34px;padding:0 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}.login .err{color:var(--p-crit)}

View File

@ -0,0 +1,42 @@
.link{font:inherit;font-size:14px;color:var(--accent);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline dotted;white-space:nowrap}
.link:hover,.link:focus-visible{text-decoration-style:solid;outline:none}
.link:disabled{color:var(--muted);cursor:default;text-decoration:none}
/* 4. Кнопки и выпадающие списки */
.btn{font:inherit;font-size:14px;line-height:1.2;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:0 12px;cursor:pointer;height:34px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;gap:5px}
.btn:hover,.btn:focus-visible{border-color:var(--accent);outline:none}
.btn[aria-pressed="true"]{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
.btn .n{font-weight:600;color:var(--p-crit)}
.btn .n.zero{font-weight:500;color:var(--muted)}
.btn[aria-pressed="true"] .n{color:inherit}
.btn.sm{height:28px;padding:0 8px;font-size:13px}
.more-btn{padding:0 10px;font-size:20px;line-height:1;letter-spacing:.05em}
.dd{position:relative;display:inline-block}
.dd-btn{max-width:360px;gap:8px;justify-content:space-between}
.dd-btn .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dd-btn .car{font-size:10px;opacity:.7}
.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 10px 28px rgba(0,0,0,.18);padding:8px;display:flex;flex-direction:column;gap:6px}
.dd-menu input[type=search]{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box}
.dd-menu input[type=search]:focus-visible{border-color:var(--accent);outline:none}
.dd-list{max-height:260px;overflow:auto;display:flex;flex-direction:column}
.dd-item{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:5px;cursor:pointer;font-size:14px}
.dd-item:hover{background:var(--soft)}
.dd-item input{accent-color:var(--accent);margin:0;width:14px;height:14px;flex:0 0 auto}
.dd-item .n{margin-left:auto;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
.dd-item.none{color:var(--muted);font-style:italic}
.dd-item.zero{opacity:.5}
.dd-foot{display:flex;gap:6px;justify-content:space-between;border-top:1px solid var(--line);padding-top:6px}
.dd-foot .btn{height:30px;padding:0 10px;font-size:13.5px}
.dd-empty{font-size:12.5px;color:var(--muted);padding:6px}
.dd-sep{border:0;border-top:1px solid var(--line);margin:4px 0}
/* меню «⋯»: открывается к левому краю, пункты с галочкой — переключатели */
.dd-menu.menu{right:0;left:auto;min-width:230px;padding:6px}
.menu button{font:inherit;font-size:14px;text-align:left;background:none;border:0;border-radius:5px;padding:7px 10px;color:var(--fg);cursor:pointer;width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px}
.menu button:hover,.menu button:focus-visible{background:var(--soft);outline:none}
.menu button[aria-pressed="true"]::after{content:"✓";color:var(--accent);font-weight:600}
.menu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
.menu .danger{color:var(--p-crit)}
.confirm{display:flex;flex-direction:column;gap:6px;padding:6px 10px;font-size:14px}
.confirm .row{flex-wrap:nowrap}
.confirm .btn{height:28px;padding:0 10px;width:auto;flex:1 1 0}
.confirm .btn.yes{background:var(--p-crit);color:#fff;border-color:var(--p-crit)}

19
web/src/styles/theme.css Normal file
View File

@ -0,0 +1,19 @@
/* 1. Цвета и темы: светлая по умолчанию, тёмная по системе или по выбору в меню */
:root{
--bg:#f3f4f1; --fg:#1f2321; --muted:#6b7069; --line:#d6d8d0; --card:#ffffff; --panel:#ffffff; --soft:#eaece6;
--accent:#1f6f8b; --accent-fg:#ffffff; --edge:#9a9f97; --edge-strong:#1f6f8b; --guide:#c2410c;
--k-decision:#b4542a; --k-analytics:#8a6d1f; --k-dev:#1f6f8b; --k-ui:#5b4b9a; --k-test:#2f7d4f; --k-org:#6b7069;
--p-crit:#b23a3a; --p-high:#c7772a; --p-mid:#6b7069; --p-low:#9a9f97;
--font-body:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
--font-mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
}
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }}
:root[data-theme="dark"]{
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }