diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f60b666..f82b69c 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); - 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(); + expect(await screen.findByTestId("page-board")).toBeInTheDocument(); + }); + + it("переход «Задачи» и обратно", async () => { + const router = at("/app/board"); + render(); + 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(); + expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); + }); + + it("«Сменить токен» показывает экран входа", async () => { + render(); + 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: , children: [{ path: "x", element: }] }], + { initialEntries: ["/x"] }); + render(); + expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument(); + }); + + it("без токена — только экран входа", () => { + clearToken(); + render(); + expect(screen.queryByRole("link", { name: "Доска" })).not.toBeInTheDocument(); + }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index e570ce5..1ad4257 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,3 +1,23 @@ -export function App() { - return

Хаб Jira

; +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; + +export function App({ router }: { router?: Router }) { + const [client] = useState(makeQueryClient); + const [r] = useState(() => router ?? createBrowserRouter(routes, { basename: "/app" })); + return ( + + + + ); } +App.Layout = Layout; diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx new file mode 100644 index 0000000..202112f --- /dev/null +++ b/web/src/board/BoardPage.tsx @@ -0,0 +1,2 @@ +// временная страница (заменяется в Task 11) +export function BoardPage() { return
; } diff --git a/web/src/issues/IssuePage.tsx b/web/src/issues/IssuePage.tsx new file mode 100644 index 0000000..e04fb22 --- /dev/null +++ b/web/src/issues/IssuePage.tsx @@ -0,0 +1,2 @@ +// временная страница (заменяется в Task 14) +export function IssuePage() { return
; } diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx new file mode 100644 index 0000000..2826f58 --- /dev/null +++ b/web/src/issues/IssuesPage.tsx @@ -0,0 +1,2 @@ +// временная страница (заменяется в Task 13) +export function IssuesPage() { return
; } diff --git a/web/src/main.tsx b/web/src/main.tsx index a1b5c62..2fd256e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( diff --git a/web/src/routes.tsx b/web/src/routes.tsx new file mode 100644 index 0000000..c120fcf --- /dev/null +++ b/web/src/routes.tsx @@ -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: , + children: [ + { index: true, element: }, + { path: "board", element: }, + { path: "issues", element: }, + { path: "issues/:key", element: }, + { path: "*", element: }, + ], +}]; diff --git a/web/src/shell/ErrorBoundary.tsx b/web/src/shell/ErrorBoundary.tsx new file mode 100644 index 0000000..0a8e2d8 --- /dev/null +++ b/web/src/shell/ErrorBoundary.tsx @@ -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 ( +
+

Страница сломалась

+

{this.state.error.message}

+ +
+ ); + } +} diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx new file mode 100644 index 0000000..f08a4b3 --- /dev/null +++ b/web/src/shell/Layout.tsx @@ -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 ( + <> +
+ Хаб Jira + + + +
+
+ +
+ + ); +} diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx new file mode 100644 index 0000000..8a830de --- /dev/null +++ b/web/src/shell/NotFound.tsx @@ -0,0 +1,10 @@ +import { Link } from "react-router"; + +export function NotFound() { + return ( +
+

Страница не найдена

+

Разделы приложения: доска, задачи.

+
+ ); +} diff --git a/web/src/shell/shell.css b/web/src/shell/shell.css new file mode 100644 index 0000000..3ea927e --- /dev/null +++ b/web/src/shell/shell.css @@ -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} diff --git a/web/src/shell/theme.test.ts b/web/src/shell/theme.test.ts new file mode 100644 index 0000000..ffec89c --- /dev/null +++ b/web/src/shell/theme.test.ts @@ -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"); + }); +}); diff --git a/web/src/shell/theme.ts b/web/src/shell/theme.ts new file mode 100644 index 0000000..3918743 --- /dev/null +++ b/web/src/shell/theme.ts @@ -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 = { "": "системная", 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"); +} diff --git a/web/src/styles/base.css b/web/src/styles/base.css new file mode 100644 index 0000000..c9878e9 --- /dev/null +++ b/web/src/styles/base.css @@ -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)} diff --git a/web/src/styles/controls.css b/web/src/styles/controls.css new file mode 100644 index 0000000..fe53af7 --- /dev/null +++ b/web/src/styles/controls.css @@ -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)} + diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css new file mode 100644 index 0000000..a2044ca --- /dev/null +++ b/web/src/styles/theme.css @@ -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 }