- T3.3/T4.6/O-2: *.tsbuildinfo в .gitignore - T3.4: cardState принимает depth только целым неотрицательным, иначе 0 - T4.1: отказ от закрытия («Остаться») сбрасывает closingDirect через CloseAbortContext - T4.4: у <dialog> запасное имя aria-label = ключ задачи (загрузка, 404, ошибка) - T5.1: проверка закрытия формы заметки в тесте T14.1 - T5.3: уникальный key строки связи (индекс + ключ + тег) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
70 lines
3.5 KiB
TypeScript
70 lines
3.5 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||
import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router";
|
||
import { clearToken } from "../auth/token.ts";
|
||
import { CloseAbortContext } from "../issues/closeAbort.ts";
|
||
import { IssueModal } from "../issues/IssueModal.tsx";
|
||
import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
|
||
import { ErrorBoundary } from "./ErrorBoundary.tsx";
|
||
import { PAGE_ROUTES } from "./pages.tsx";
|
||
import "./shell.css";
|
||
|
||
export function Layout() {
|
||
const qc = useQueryClient();
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null;
|
||
const card = issueKey ? cardState(location.state) : null;
|
||
const bgPath = issueKey ? (card?.background ?? LIST_BACKGROUND).pathname : null;
|
||
const bgSearch = issueKey ? (card?.background ?? LIST_BACKGROUND).search : "";
|
||
// адрес страницы под окном; передаётся в useRoutes всегда (и без окна), чтобы дерево страницы не менялось при
|
||
// открытии окна и useSearchParams внутри неё видел её собственный адрес (design 10)
|
||
const bgLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "card-background" },
|
||
[bgPath, bgSearch]);
|
||
const pageLocation = bgLocation ?? location;
|
||
const page = useRoutes(PAGE_ROUTES, pageLocation);
|
||
|
||
// прямой адрес карточки: запись истории получает state окна поверх списка задач (design 11)
|
||
useEffect(() => {
|
||
if (issueKey && !card) {
|
||
void navigate(location.pathname + location.search, { replace: true, state: { background: LIST_BACKGROUND, depth: 0, direct: true } });
|
||
}
|
||
}, [issueKey, card, location.pathname, location.search, navigate]);
|
||
|
||
// закрытие (design 12): назад на depth записей; запись прямого адреса, на которую вернулись, заменяется списком
|
||
const closingDirect = useRef(false);
|
||
useEffect(() => {
|
||
if (closingDirect.current && card?.direct && card.depth === 0) {
|
||
closingDirect.current = false;
|
||
void navigate("/issues", { replace: true });
|
||
}
|
||
}, [card, navigate]);
|
||
const close = useCallback(() => {
|
||
if (!card || card.depth === 0) { void navigate("/issues", { replace: true }); return; }
|
||
closingDirect.current = card.direct;
|
||
void navigate(-card.depth);
|
||
}, [card, navigate]);
|
||
|
||
const abortClose = useCallback(() => { closingDirect.current = false; }, []);
|
||
|
||
const at = pageLocation.pathname;
|
||
const tab = (to: string, label: string) => {
|
||
const active = at === to || at.startsWith(to + "/");
|
||
return <Link to={to} className={active ? "active" : undefined} aria-current={active ? "page" : undefined}>{label}</Link>;
|
||
};
|
||
return (
|
||
<>
|
||
<header className="appbar">
|
||
<span className="brand">Хаб Jira</span>
|
||
<nav>{tab("/board", "Доска")}{tab("/issues", "Задачи")}</nav>
|
||
<span className="spacer" />
|
||
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
|
||
</header>
|
||
<main className="page">
|
||
<ErrorBoundary key={at}>{page}</ErrorBoundary>
|
||
</main>
|
||
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
|
||
</>
|
||
);
|
||
}
|