Unify page workspace error panels

This commit is contained in:
DCCONSTRUCTIONS 2026-06-28 19:48:20 +03:00
parent 7be6104b6b
commit 942373563d
2 changed files with 320 additions and 276 deletions

View File

@ -1039,6 +1039,10 @@ function getWorkspaceKey(projectId: string, scopeId: string) {
return `${projectId}:${scopeId}`; return `${projectId}:${scopeId}`;
} }
function getWorkspaceUiKeyFromWorkspaceKey(workspaceKey: string) {
return workspaceKey.split(":")[0] ?? workspaceKey;
}
type WorkspaceSection = PageWorkspace["sections"][number]; type WorkspaceSection = PageWorkspace["sections"][number];
type WorkspaceSectionTextMap = Record<string, string>; type WorkspaceSectionTextMap = Record<string, string>;
@ -1059,7 +1063,9 @@ type WorkspaceTarget = {
}; };
type WorkspacePreviewMode = "desktop" | "mobile"; type WorkspacePreviewMode = "desktop" | "mobile";
type WorkspaceDrawerPanel = "fields" | "issues"; type WorkspaceDrawerPanel = "pages" | "issues";
const WORKSPACE_DRAWER_ORDER: WorkspaceDrawerPanel[] = ["pages", "issues"];
const WORKSPACE_PREVIEW_MODES: Array<{ id: WorkspacePreviewMode; label: string }> = [ const WORKSPACE_PREVIEW_MODES: Array<{ id: WorkspacePreviewMode; label: string }> = [
{ id: "desktop", label: "Desktop" }, { id: "desktop", label: "Desktop" },
@ -1500,7 +1506,6 @@ export function App() {
const [activeWorkspaceTargetIds, setActiveWorkspaceTargetIds] = useState<Record<string, string>>({}); const [activeWorkspaceTargetIds, setActiveWorkspaceTargetIds] = useState<Record<string, string>>({});
const [workspacePreviewMarkers, setWorkspacePreviewMarkers] = useState<Record<string, PreviewMarker[]>>({}); const [workspacePreviewMarkers, setWorkspacePreviewMarkers] = useState<Record<string, PreviewMarker[]>>({});
const [workspaceDrawers, setWorkspaceDrawers] = useState<Record<string, WorkspaceDrawerPanel[]>>({}); const [workspaceDrawers, setWorkspaceDrawers] = useState<Record<string, WorkspaceDrawerPanel[]>>({});
const [workspacePageMenus, setWorkspacePageMenus] = useState<Record<string, boolean>>({});
const [workspacePreviewScopeIds, setWorkspacePreviewScopeIds] = useState<Record<string, string>>({}); const [workspacePreviewScopeIds, setWorkspacePreviewScopeIds] = useState<Record<string, string>>({});
const [pageWorkspaces, setPageWorkspaces] = useState<Record<string, PageWorkspace>>({}); const [pageWorkspaces, setPageWorkspaces] = useState<Record<string, PageWorkspace>>({});
const [workspaceDraftTexts, setWorkspaceDraftTexts] = useState<Record<string, string>>({}); const [workspaceDraftTexts, setWorkspaceDraftTexts] = useState<Record<string, string>>({});
@ -1760,11 +1765,6 @@ export function App() {
return; return;
} }
if (data.type === "seo-mode:preview-pointerdown") {
closeWorkspacePageMenus();
return;
}
if (data.type !== "seo-mode:preview-markers") { if (data.type !== "seo-mode:preview-markers") {
if (data?.type === "seo-mode:active-scope-page") { if (data?.type === "seo-mode:active-scope-page") {
const sourceWorkspaceKey = typeof data.workspaceKey === "string" ? data.workspaceKey : ""; const sourceWorkspaceKey = typeof data.workspaceKey === "string" ? data.workspaceKey : "";
@ -1832,7 +1832,7 @@ export function App() {
...currentSectionIds, ...currentSectionIds,
[workspaceKey]: sectionId || targetId [workspaceKey]: sectionId || targetId
})); }));
openWorkspaceDrawer(workspaceKey, "fields"); openWorkspaceDrawer(getWorkspaceUiKeyFromWorkspaceKey(workspaceKey), "issues");
scrollWorkspaceFieldIntoView(workspaceKey, targetId); scrollWorkspaceFieldIntoView(workspaceKey, targetId);
return; return;
@ -2550,7 +2550,7 @@ export function App() {
...currentSectionIds, ...currentSectionIds,
[workspaceKey]: target.sectionId [workspaceKey]: target.sectionId
})); }));
openWorkspaceDrawer(workspaceKey, "fields"); openWorkspaceDrawer(getWorkspaceUiKeyFromWorkspaceKey(workspaceKey), "issues");
scrollWorkspaceFieldIntoView(workspaceKey, target.id); scrollWorkspaceFieldIntoView(workspaceKey, target.id);
} }
@ -2599,49 +2599,6 @@ export function App() {
}); });
} }
function toggleWorkspacePageMenu(workspaceKey: string) {
setWorkspacePageMenus((currentMenus) => ({
...currentMenus,
[workspaceKey]: !currentMenus[workspaceKey]
}));
}
function closeWorkspacePageMenus() {
setWorkspacePageMenus((currentMenus) => {
let hasOpenMenu = false;
const nextMenus = { ...currentMenus };
Object.keys(nextMenus).forEach((workspaceKey) => {
if (nextMenus[workspaceKey]) {
nextMenus[workspaceKey] = false;
hasOpenMenu = true;
}
});
return hasOpenMenu ? nextMenus : currentMenus;
});
}
useEffect(() => {
if (!Object.values(workspacePageMenus).some(Boolean)) {
return;
}
function handleDocumentPointerDown(event: PointerEvent) {
if (event.target instanceof Element && event.target.closest(".workspace-page-control")) {
return;
}
closeWorkspacePageMenus();
}
document.addEventListener("pointerdown", handleDocumentPointerDown, true);
return () => {
document.removeEventListener("pointerdown", handleDocumentPointerDown, true);
};
}, [workspacePageMenus]);
function updateWorkspacePreviewMarkers( function updateWorkspacePreviewMarkers(
workspaceKey: string, workspaceKey: string,
targets: WorkspaceTarget[], targets: WorkspaceTarget[],
@ -2803,11 +2760,10 @@ export function App() {
); );
setWorkspaceDrawers((currentDrawers) => setWorkspaceDrawers((currentDrawers) =>
Object.fromEntries( Object.fromEntries(
Object.entries(currentDrawers).filter(([workspaceKey]) => !workspaceKey.startsWith(`${project.id}:`)) Object.entries(currentDrawers).filter(
([workspaceKey]) => workspaceKey !== project.id && !workspaceKey.startsWith(`${project.id}:`)
)
) )
);
setWorkspacePageMenus((currentMenus) =>
Object.fromEntries(Object.entries(currentMenus).filter(([workspaceKey]) => !workspaceKey.startsWith(`${project.id}:`)))
); );
setActiveWorkspaceSectionIds((currentSectionIds) => setActiveWorkspaceSectionIds((currentSectionIds) =>
Object.fromEntries( Object.fromEntries(
@ -3522,12 +3478,10 @@ export function App() {
null; null;
const activeWorkspaceMarkers = activeWorkspaceKey ? (workspacePreviewMarkers[activeWorkspaceKey] ?? []) : []; const activeWorkspaceMarkers = activeWorkspaceKey ? (workspacePreviewMarkers[activeWorkspaceKey] ?? []) : [];
const activeWorkspacePreviewMode = activeWorkspaceKey ? (workspacePreviewModes[activeWorkspaceKey] ?? "desktop") : "desktop"; const activeWorkspacePreviewMode = activeWorkspaceKey ? (workspacePreviewModes[activeWorkspaceKey] ?? "desktop") : "desktop";
const activeWorkspaceDrawers = activeWorkspaceKey const activeWorkspaceUiKey = project.id;
? (workspaceDrawers[activeWorkspaceKey] ?? []).filter( const activeWorkspaceDrawers = WORKSPACE_DRAWER_ORDER.filter((drawer) =>
(drawer): drawer is WorkspaceDrawerPanel => drawer === "fields" || drawer === "issues" (workspaceDrawers[activeWorkspaceUiKey] ?? []).includes(drawer)
) );
: [];
const isWorkspacePageMenuOpen = activeWorkspaceKey ? Boolean(workspacePageMenus[activeWorkspaceKey]) : false;
const previewWorkspaceScopeId = workspacePreviewScopeIds[project.id] ?? activeWorkspaceScopeItem?.scopeId ?? null; const previewWorkspaceScopeId = workspacePreviewScopeIds[project.id] ?? activeWorkspaceScopeItem?.scopeId ?? null;
const previewWorkspaceScopeItem = const previewWorkspaceScopeItem =
selectedWorkspaceScopeItems.find((item) => item.scopeId === previewWorkspaceScopeId) ?? selectedWorkspaceScopeItems.find((item) => item.scopeId === previewWorkspaceScopeId) ??
@ -4793,12 +4747,9 @@ export function App() {
<span>Warning: {activeWorkspaceIssueCounts.warnings} · Info: {activeWorkspaceIssueCounts.info}</span> <span>Warning: {activeWorkspaceIssueCounts.warnings} · Info: {activeWorkspaceIssueCounts.info}</span>
</div> </div>
<div className="workspace-current-page-actions"> <div className="workspace-current-page-actions">
<div className={`workspace-page-control ${isWorkspacePageMenuOpen ? "open" : ""}`}>
<button <button
aria-expanded={isWorkspacePageMenuOpen} className={activeWorkspaceDrawers.includes("pages") ? "active" : undefined}
aria-haspopup="menu" onClick={() => toggleWorkspaceDrawer(activeWorkspaceUiKey, "pages")}
className="workspace-page-control-trigger"
onClick={() => toggleWorkspacePageMenu(activeWorkspaceKey)}
type="button" type="button"
> >
<FileText size={14} /> <FileText size={14} />
@ -4808,68 +4759,13 @@ export function App() {
</em> </em>
<ChevronDown size={14} /> <ChevronDown size={14} />
</button> </button>
{isWorkspacePageMenuOpen ? (
<div className="workspace-page-menu" role="menu">
<div className="workspace-page-menu-head">
<strong>Страницы и секции</strong>
<small>Переключает preview, ошибки и поля рабочей зоны.</small>
</div>
{selectedWorkspaceScopeItems.length > 0 ? (
<div className="workspace-page-menu-list">
{selectedWorkspaceScopeItems.map((scopeItem, pageIndex) => {
const pageWorkspaceKey = getWorkspaceKey(project.id, scopeItem.scopeId);
const isActivePage = activeWorkspaceScopeItem?.scopeId === scopeItem.scopeId;
const isOpening = busyWorkspaceKey === pageWorkspaceKey;
const pageIssues = getPageIssues(scanSummary, scopeItem.pageId);
return (
<button
className={isActivePage ? "active" : undefined}
disabled={isOpening}
key={`workspace-page-menu-${scopeItem.scopeId}`}
onClick={() => {
setWorkspacePageMenus((currentMenus) => ({
...currentMenus,
[activeWorkspaceKey]: false,
[pageWorkspaceKey]: true
}));
void handleOpenPageWorkspace(project, scopeItem, { silent: true });
}}
role="menuitem"
type="button"
>
<span>{pageIndex + 1}</span>
<strong>{scopeItem.title}</strong>
<small>{scopeItem.pathLabel}</small>
<em>{pageIssues.length} замечаний</em>
</button>
);
})}
</div>
) : (
<small className="workspace-page-menu-empty">
В выбранном наборе пока нет рабочих страниц или секций.
</small>
)}
</div>
) : null}
</div>
<button <button
className={activeWorkspaceDrawers.includes("issues") ? "active" : undefined} className={activeWorkspaceDrawers.includes("issues") ? "active" : undefined}
onClick={() => toggleWorkspaceDrawer(activeWorkspaceKey, "issues")} onClick={() => toggleWorkspaceDrawer(activeWorkspaceUiKey, "issues")}
type="button" type="button"
> >
<CircleAlert size={14} /> <CircleAlert size={14} />
Ошибки Ошибки
<em>{activeWorkspaceIssues.length}</em>
</button>
<button
className={activeWorkspaceDrawers.includes("fields") ? "active" : undefined}
onClick={() => toggleWorkspaceDrawer(activeWorkspaceKey, "fields")}
type="button"
>
<FileText size={14} />
Поля
<em>{activeWorkspaceTargets.length}</em> <em>{activeWorkspaceTargets.length}</em>
</button> </button>
<div className="workspace-preview-mode-switch" aria-label="Режим зеркала"> <div className="workspace-preview-mode-switch" aria-label="Режим зеркала">
@ -4949,34 +4845,67 @@ export function App() {
</div> </div>
{activeWorkspaceDrawers.length > 0 ? ( {activeWorkspaceDrawers.length > 0 ? (
<div className="workspace-drawer-stack"> <div className="workspace-drawer-stack">
{activeWorkspaceDrawers.map((activeWorkspaceDrawer) => ( {activeWorkspaceDrawers.map((activeWorkspaceDrawer) => {
const isPagesDrawer = activeWorkspaceDrawer === "pages";
return (
<aside className="workspace-drawer open" key={`workspace-drawer-${activeWorkspaceDrawer}`}> <aside className="workspace-drawer open" key={`workspace-drawer-${activeWorkspaceDrawer}`}>
<div className="workspace-drawer-header"> <div className="workspace-drawer-header">
<div> <div>
<span> <span>{isPagesDrawer ? "Страницы и секции" : "Ошибки страницы"}</span>
{activeWorkspaceDrawer === "fields" ? "Поля для правки" : "Аудит страницы"}
</span>
<strong> <strong>
{activeWorkspaceDrawer === "fields" {isPagesDrawer
? `${activeWorkspaceTargets.length} полей` ? `${activeWorkspacePageIndex + 1}/${selectedWorkspaceScopeItems.length}`
: `${activeWorkspaceIssues.length} замечаний`} : `${activeWorkspaceTargets.length} замечаний`}
</strong> </strong>
<small> <small>
{activeWorkspaceDrawer === "fields" {isPagesDrawer
? "Клик по точке или карточке открывает связанное поле." ? "Переключает preview и ошибки рабочей зоны."
: "Список относится к активной странице, не ко всему сайту."} : "Карточки относятся к активной странице."}
</small> </small>
</div> </div>
<button <button
aria-label="Закрыть панель" aria-label="Закрыть панель"
className="workspace-drawer-close" className="workspace-drawer-close"
onClick={() => closeWorkspaceDrawer(activeWorkspaceKey, activeWorkspaceDrawer)} onClick={() => closeWorkspaceDrawer(activeWorkspaceUiKey, activeWorkspaceDrawer)}
type="button" type="button"
> >
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
{activeWorkspaceDrawer === "fields" ? ( {isPagesDrawer ? (
selectedWorkspaceScopeItems.length > 0 ? (
<div className="workspace-page-menu-list workspace-drawer-page-list">
{selectedWorkspaceScopeItems.map((scopeItem, pageIndex) => {
const pageWorkspaceKey = getWorkspaceKey(project.id, scopeItem.scopeId);
const isActivePage = activeWorkspaceScopeItem?.scopeId === scopeItem.scopeId;
const isOpening = busyWorkspaceKey === pageWorkspaceKey;
const pageIssues = getPageIssues(scanSummary, scopeItem.pageId);
return (
<button
className={isActivePage ? "active" : undefined}
disabled={isOpening}
key={`workspace-page-drawer-${scopeItem.scopeId}`}
onClick={() => {
void handleOpenPageWorkspace(project, scopeItem, { silent: true });
}}
type="button"
>
<span>{pageIndex + 1}</span>
<strong>{scopeItem.title}</strong>
<small>{scopeItem.pathLabel}</small>
<em>{pageIssues.length} замечаний</em>
</button>
);
})}
</div>
) : (
<small className="workspace-page-menu-empty">
В выбранном наборе пока нет рабочих страниц или секций.
</small>
)
) : (
<div className="workspace-fields-drawer"> <div className="workspace-fields-drawer">
{activeWorkspaceTargets.length > 0 ? ( {activeWorkspaceTargets.length > 0 ? (
activeWorkspaceTargets.map((target) => { activeWorkspaceTargets.map((target) => {
@ -4999,19 +4928,29 @@ export function App() {
</strong> </strong>
<small>{target.selector ?? "selector пока не задан"}</small> <small>{target.selector ?? "selector пока не задан"}</small>
</div> </div>
{target.issues.length > 0 ? (
<div className="workspace-target-issues">
{target.issues.map((issue) => {
const explanation = getAuditIssueExplanation(issue.code);
return (
<article
className="workspace-target-issue"
key={`target-issue-${target.id}-${issue.id}`}
>
<span>{getAuditSeverityLabel(issue.severity)}</span>
<strong>{issue.message}</strong>
<small>{issue.field ? `Поле: ${issue.field}` : "Медиа/страница"}</small>
<p>{explanation.action}</p>
</article>
);
})}
</div>
) : null}
<div className="workspace-field-meta"> <div className="workspace-field-meta">
<span>{getSectionKindLabel(target.kind)}</span> <span>{getSectionKindLabel(target.kind)}</span>
<em>{target.workingText.length} симв.</em> <em>{target.workingText.length} симв.</em>
</div> </div>
{target.issues.length > 0 ? (
<div className="workspace-target-issues">
{target.issues.map((issue) => (
<small key={`target-issue-${target.id}-${issue.id}`}>
{getAuditSeverityLabel(issue.severity)} · {issue.message}
</small>
))}
</div>
) : null}
<label> <label>
<span>Original · только чтение</span> <span>Original · только чтение</span>
<pre>{target.originalText || "Пусто"}</pre> <pre>{target.originalText || "Пусто"}</pre>
@ -5069,42 +5008,13 @@ export function App() {
); );
}) })
) : ( ) : (
<small className="workspace-empty">Для этой страницы пока нет полей, связанных с замечаниями.</small> <small className="workspace-empty">Для этой страницы пока нет ошибок, связанных с полями.</small>
)} )}
</div> </div>
) : (
<>
{activeWorkspaceIssues.length > 0 ? (
<div className="workspace-issue-list">
{activeWorkspaceIssues.map((issue) => {
const target = activeWorkspaceTargets.find((item) => item.issues.some((targetIssue) => targetIssue.id === issue.id));
const explanation = getAuditIssueExplanation(issue.code);
return (
<button
disabled={!target}
key={`workspace-issue-${issue.id}`}
onClick={() => {
if (!target) return;
activateWorkspaceTarget(activeWorkspaceKey, target);
}}
type="button"
>
<span>{getAuditSeverityLabel(issue.severity)}</span>
<strong>{issue.message}</strong>
<small>{issue.field ? `Поле: ${issue.field}` : "Медиа/страница"}</small>
<p>{explanation.action}</p>
</button>
);
})}
</div>
) : (
<small className="workspace-empty">По активной странице ошибок базового аудита нет.</small>
)}
</>
)} )}
</aside> </aside>
))} );
})}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -7926,3 +7926,137 @@ textarea:focus {
background: var(--nodedc-glass-control-bg) !important; background: var(--nodedc-glass-control-bg) !important;
border: 0 !important; border: 0 !important;
} }
.stage-3 .workspace-drawer-page-list {
min-height: 0;
max-height: none;
overflow: auto;
padding: 12px;
}
.stage-3 .workspace-page-menu-list button,
.stage-3 .workspace-drawer-page-list button {
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 4px 10px;
min-height: 68px;
padding: 10px 12px;
}
.stage-3 .workspace-page-menu-list button span,
.stage-3 .workspace-drawer-page-list button span {
grid-column: 1;
grid-row: 1 / span 3;
align-self: center;
width: 26px;
min-width: 26px;
height: 26px;
}
.stage-3 .workspace-page-menu-list button strong,
.stage-3 .workspace-page-menu-list button small,
.stage-3 .workspace-page-menu-list button em,
.stage-3 .workspace-drawer-page-list button strong,
.stage-3 .workspace-drawer-page-list button small,
.stage-3 .workspace-drawer-page-list button em {
grid-column: 2;
min-width: 0;
}
.stage-3 .workspace-page-menu-list button em,
.stage-3 .workspace-drawer-page-list button em {
grid-row: auto;
justify-self: start;
align-self: start;
min-width: max-content;
min-height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 9px;
line-height: 1;
}
.stage-3 .workspace-page-menu-list button.active,
.stage-3 .workspace-drawer-page-list button.active,
.workspace-fields-drawer .workspace-field-card.active {
background: var(--nodedc-header-dropdown-item-active-bg) !important;
box-shadow: var(--nodedc-glass-control-shadow), 0 0 0 1px rgba(24, 32, 29, 0.12) inset !important;
}
.stage-3 .workspace-page-menu-list button.active span,
.stage-3 .workspace-drawer-page-list button.active span {
color: #fff !important;
background: var(--ink) !important;
}
.stage-3 .workspace-page-menu-list button.active strong,
.stage-3 .workspace-page-menu-list button.active small,
.stage-3 .workspace-page-menu-list button.active em,
.stage-3 .workspace-drawer-page-list button.active strong,
.stage-3 .workspace-drawer-page-list button.active small,
.stage-3 .workspace-drawer-page-list button.active em {
color: var(--ink) !important;
}
.stage-3 .workspace-target-issues {
display: grid;
gap: 8px;
padding: 0;
background: transparent !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
.workspace-target-issue {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 4px 8px;
padding: 10px;
color: var(--ink);
background: var(--nodedc-glass-control-bg);
border: 0;
border-radius: 14px;
box-shadow: var(--nodedc-glass-control-shadow);
backdrop-filter: var(--nodedc-glass-blur);
-webkit-backdrop-filter: var(--nodedc-glass-blur);
}
.workspace-target-issue span {
grid-row: 1 / span 3;
align-self: start;
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
color: var(--muted) !important;
background: rgba(24, 32, 29, 0.06) !important;
border-radius: 999px;
font-size: 11px;
font-weight: 760;
line-height: 1;
}
.workspace-target-issue strong,
.workspace-target-issue small,
.workspace-target-issue p {
grid-column: 2;
min-width: 0;
}
.workspace-target-issue strong {
color: var(--ink);
font-size: 13px;
font-weight: 760;
line-height: 1.22;
}
.workspace-target-issue small,
.workspace-target-issue p {
margin: 0;
color: var(--muted);
font-size: 12px;
font-weight: 520;
line-height: 1.35;
}