sdlc-hub/web/src/shell/Layout.tsx
Andrey Epifancev 2a472cacbf style(web): вход, новый проект, служебные экраны; старые стили убраны (#65)
Экраны входа, «Новый проект», ProjectGate, «не найдено» и ошибка отрисовки
переведены на компоненты системы. controls.css удалён, псевдонимы токенов
убраны, глобальные .row/.spacer/.hint/.warn/.login разобраны. Разделы в
верхней панели не активны вне доски и задач. Тонкая настройка дорожек доски,
тест на ошибку списка, стабилизирован тест проектов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:19:09 +04:00

90 lines
5.4 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 { 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>
);
}