Экраны входа, «Новый проект», ProjectGate, «не найдено» и ошибка отрисовки переведены на компоненты системы. controls.css удалён, псевдонимы токенов убраны, глобальные .row/.spacer/.hint/.warn/.login разобраны. Разделы в верхней панели не активны вне доски и задач. Тонкая настройка дорожек доски, тест на ошибку списка, стабилизирован тест проектов. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
90 lines
5.4 KiB
TypeScript
90 lines
5.4 KiB
TypeScript
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 { AppBar } from "./AppBar.tsx";
|
||
import { ErrorBoundary } from "./ErrorBoundary.tsx";
|
||
import { PAGE_ROUTES } from "./pages.tsx";
|
||
import { useJiraRefresh } from "./refresh.ts";
|
||
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";
|
||
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 : null;
|
||
const select = useCallback((label: string) => { void navigate({ pathname: section ?? "/board", search: withProject("", label) }); }, [navigate, section]);
|
||
const ctx = useMemo<ProjectCtx>(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }),
|
||
[state, projects.data, select]);
|
||
|
||
const refresh = useJiraRefresh();
|
||
return (
|
||
<ProjectContext value={ctx}>
|
||
<AppBar section={section} project={okLabel} />
|
||
<main className={"page" + (section !== "/issues" ? " page--board" : "")}>
|
||
<ErrorBoundary key={at}>{page}</ErrorBoundary>
|
||
</main>
|
||
{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>
|
||
);
|
||
}
|