sdlc-hub/web/src/shell/Layout.tsx
Andrey Epifancev e0d8eee5b4 Лента: follow-up ревью M-1…M-7, README по журналу изменений (#77, #76)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:09:52 +04:00

95 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { matchPath, useLocation, useNavigate, useRoutes } from "react-router";
import { useProjects } from "../api/queries.ts";
import { CloseAbortContext } from "../issues/closeAbort.ts";
import { IssueModal } from "../issues/IssueModal.tsx";
import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts";
import { NewProjectDialog } from "../projects/NewProjectDialog.tsx";
import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts";
import { ErrorBoundary } from "./ErrorBoundary.tsx";
import { PAGE_ROUTES } from "./pages.tsx";
import { useJiraRefresh } from "./refresh.ts";
import { Sidebar } from "./Sidebar.tsx";
import "./shell.css";
export function Layout() {
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 projects = useProjects();
const urlLabel = new URLSearchParams(pageLocation.search).get("project");
const state = useMemo<ProjectState>(() => projects.isPending ? { kind: "loading" }
: projects.error && !projects.data ? { kind: "error", error: projects.error } // ошибка фонового рефетча — остаётся последний список
: resolveProject(urlLabel, rememberedProject(), projects.data), [projects.isPending, projects.error, projects.data, urlLabel]);
const okLabel = state.kind === "ok" ? state.project.label : null;
const fromUrl = state.kind === "ok" && state.fromUrl;
const onPage = at === "/board" || at === "/issues" || at === "/feed";
useEffect(() => { if (okLabel) rememberProject(okLabel); }, [okLabel]);
// выбранный проект записывается в адрес без новой записи истории; с открытой карточкой адрес не трогаем (design 10)
useEffect(() => {
if (okLabel && !fromUrl && issueKey === null && onPage) {
void navigate(at + withProject(pageLocation.search, okLabel), { replace: true });
}
}, [okLabel, fromUrl, issueKey, onPage, at, pageLocation.search, navigate]);
const [newOpen, setNewOpen] = useState(false);
const section = at === "/issues" || at === "/board" || at === "/feed" ? at : null;
// смена проекта: фильтры доски и списка — проектные и сбрасываются; период и виды ленты от проекта не зависят (R-1)
const select = useCallback((label: string) => {
void navigate({ pathname: section ?? "/board", search: withProject(section === "/feed" ? pageLocation.search : "", label) });
}, [navigate, section, pageLocation.search]);
const ctx = useMemo<ProjectCtx>(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
[state, projects.data, select]);
const refresh = useJiraRefresh();
return (
<ProjectContext value={ctx}>
<div className="shell">
<Sidebar section={section} project={okLabel} />
<main className={"page" + (section !== "/issues" && section !== "/feed" ? " page--board" : "")}>
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
</div>
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
{newOpen && <NewProjectDialog onClose={() => setNewOpen(false)} onCreated={(p, batchId) => { setNewOpen(false); refresh.track(batchId); select(p.label); }} />}
</ProjectContext>
);
}