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}`;
}
function getWorkspaceUiKeyFromWorkspaceKey(workspaceKey: string) {
return workspaceKey.split(":")[0] ?? workspaceKey;
}
type WorkspaceSection = PageWorkspace["sections"][number];
type WorkspaceSectionTextMap = Record<string, string>;
@ -1059,7 +1063,9 @@ type WorkspaceTarget = {
};
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 }> = [
{ id: "desktop", label: "Desktop" },
@ -1500,7 +1506,6 @@ export function App() {
const [activeWorkspaceTargetIds, setActiveWorkspaceTargetIds] = useState<Record<string, string>>({});
const [workspacePreviewMarkers, setWorkspacePreviewMarkers] = useState<Record<string, PreviewMarker[]>>({});
const [workspaceDrawers, setWorkspaceDrawers] = useState<Record<string, WorkspaceDrawerPanel[]>>({});
const [workspacePageMenus, setWorkspacePageMenus] = useState<Record<string, boolean>>({});
const [workspacePreviewScopeIds, setWorkspacePreviewScopeIds] = useState<Record<string, string>>({});
const [pageWorkspaces, setPageWorkspaces] = useState<Record<string, PageWorkspace>>({});
const [workspaceDraftTexts, setWorkspaceDraftTexts] = useState<Record<string, string>>({});
@ -1760,11 +1765,6 @@ export function App() {
return;
}
if (data.type === "seo-mode:preview-pointerdown") {
closeWorkspacePageMenus();
return;
}
if (data.type !== "seo-mode:preview-markers") {
if (data?.type === "seo-mode:active-scope-page") {
const sourceWorkspaceKey = typeof data.workspaceKey === "string" ? data.workspaceKey : "";
@ -1832,7 +1832,7 @@ export function App() {
...currentSectionIds,
[workspaceKey]: sectionId || targetId
}));
openWorkspaceDrawer(workspaceKey, "fields");
openWorkspaceDrawer(getWorkspaceUiKeyFromWorkspaceKey(workspaceKey), "issues");
scrollWorkspaceFieldIntoView(workspaceKey, targetId);
return;
@ -2550,7 +2550,7 @@ export function App() {
...currentSectionIds,
[workspaceKey]: target.sectionId
}));
openWorkspaceDrawer(workspaceKey, "fields");
openWorkspaceDrawer(getWorkspaceUiKeyFromWorkspaceKey(workspaceKey), "issues");
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(
workspaceKey: string,
targets: WorkspaceTarget[],
@ -2803,12 +2760,11 @@ export function App() {
);
setWorkspaceDrawers((currentDrawers) =>
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) =>
Object.fromEntries(
Object.entries(currentSectionIds).filter(([workspaceKey]) => !workspaceKey.startsWith(`${project.id}:`))
@ -3522,12 +3478,10 @@ export function App() {
null;
const activeWorkspaceMarkers = activeWorkspaceKey ? (workspacePreviewMarkers[activeWorkspaceKey] ?? []) : [];
const activeWorkspacePreviewMode = activeWorkspaceKey ? (workspacePreviewModes[activeWorkspaceKey] ?? "desktop") : "desktop";
const activeWorkspaceDrawers = activeWorkspaceKey
? (workspaceDrawers[activeWorkspaceKey] ?? []).filter(
(drawer): drawer is WorkspaceDrawerPanel => drawer === "fields" || drawer === "issues"
)
: [];
const isWorkspacePageMenuOpen = activeWorkspaceKey ? Boolean(workspacePageMenus[activeWorkspaceKey]) : false;
const activeWorkspaceUiKey = project.id;
const activeWorkspaceDrawers = WORKSPACE_DRAWER_ORDER.filter((drawer) =>
(workspaceDrawers[activeWorkspaceUiKey] ?? []).includes(drawer)
);
const previewWorkspaceScopeId = workspacePreviewScopeIds[project.id] ?? activeWorkspaceScopeItem?.scopeId ?? null;
const previewWorkspaceScopeItem =
selectedWorkspaceScopeItems.find((item) => item.scopeId === previewWorkspaceScopeId) ??
@ -4793,83 +4747,25 @@ export function App() {
<span>Warning: {activeWorkspaceIssueCounts.warnings} · Info: {activeWorkspaceIssueCounts.info}</span>
</div>
<div className="workspace-current-page-actions">
<div className={`workspace-page-control ${isWorkspacePageMenuOpen ? "open" : ""}`}>
<button
aria-expanded={isWorkspacePageMenuOpen}
aria-haspopup="menu"
className="workspace-page-control-trigger"
onClick={() => toggleWorkspacePageMenu(activeWorkspaceKey)}
type="button"
>
<FileText size={14} />
Страница
<em>
{activeWorkspacePageIndex + 1}/{selectedWorkspaceScopeItems.length}
</em>
<ChevronDown size={14} />
</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
className={activeWorkspaceDrawers.includes("pages") ? "active" : undefined}
onClick={() => toggleWorkspaceDrawer(activeWorkspaceUiKey, "pages")}
type="button"
>
<FileText size={14} />
Страница
<em>
{activeWorkspacePageIndex + 1}/{selectedWorkspaceScopeItems.length}
</em>
<ChevronDown size={14} />
</button>
<button
className={activeWorkspaceDrawers.includes("issues") ? "active" : undefined}
onClick={() => toggleWorkspaceDrawer(activeWorkspaceKey, "issues")}
onClick={() => toggleWorkspaceDrawer(activeWorkspaceUiKey, "issues")}
type="button"
>
<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>
</button>
<div className="workspace-preview-mode-switch" aria-label="Режим зеркала">
@ -4949,162 +4845,176 @@ export function App() {
</div>
{activeWorkspaceDrawers.length > 0 ? (
<div className="workspace-drawer-stack">
{activeWorkspaceDrawers.map((activeWorkspaceDrawer) => (
<aside className="workspace-drawer open" key={`workspace-drawer-${activeWorkspaceDrawer}`}>
<div className="workspace-drawer-header">
<div>
<span>
{activeWorkspaceDrawer === "fields" ? "Поля для правки" : "Аудит страницы"}
</span>
<strong>
{activeWorkspaceDrawer === "fields"
? `${activeWorkspaceTargets.length} полей`
: `${activeWorkspaceIssues.length} замечаний`}
</strong>
<small>
{activeWorkspaceDrawer === "fields"
? "Клик по точке или карточке открывает связанное поле."
: "Список относится к активной странице, не ко всему сайту."}
</small>
</div>
<button
aria-label="Закрыть панель"
className="workspace-drawer-close"
onClick={() => closeWorkspaceDrawer(activeWorkspaceKey, activeWorkspaceDrawer)}
type="button"
>
<X size={16} />
</button>
</div>
{activeWorkspaceDrawer === "fields" ? (
<div className="workspace-fields-drawer">
{activeWorkspaceTargets.length > 0 ? (
activeWorkspaceTargets.map((target) => {
const targetSectionText =
workspaceSectionTexts[activeWorkspaceKey]?.[target.sectionId] ?? target.workingText;
{activeWorkspaceDrawers.map((activeWorkspaceDrawer) => {
const isPagesDrawer = activeWorkspaceDrawer === "pages";
return (
<div
className={
activeWorkspaceTarget?.id === target.id
? "section-editor-card workspace-field-card active"
: "section-editor-card workspace-field-card"
}
id={getWorkspaceFieldDomId(activeWorkspaceKey, target.id)}
key={`workspace-field-${target.id}`}
>
<div className="workspace-subheading">
<strong>
{target.number}. {target.title}
</strong>
<small>{target.selector ?? "selector пока не задан"}</small>
</div>
<div className="workspace-field-meta">
<span>{getSectionKindLabel(target.kind)}</span>
<em>{target.workingText.length} симв.</em>
</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>
<span>Original · только чтение</span>
<pre>{target.originalText || "Пусто"}</pre>
</label>
<label>
<span>Working · редактируется</span>
<textarea
onChange={(event) =>
setWorkspaceSectionTexts((currentTexts) => ({
...currentTexts,
[activeWorkspaceKey]: {
...(currentTexts[activeWorkspaceKey] ?? {}),
[target.sectionId]: event.target.value
}
}))
}
value={targetSectionText}
/>
</label>
<div className="workspace-actions">
<button
className="tiny-action primary"
disabled={isWorkspaceBusy}
onClick={() =>
void handleSavePageWorkspace(
project.id,
activeWorkspace.pageId,
activeWorkspaceKey
)
}
type="button"
>
{isWorkspaceBusy ? <Loader2 className="spin" size={14} /> : <Save size={14} />}
Сохранить draft
</button>
<button
className="tiny-action"
disabled={isWorkspaceBusy}
onClick={() =>
setWorkspaceSectionTexts((currentTexts) => ({
...currentTexts,
[activeWorkspaceKey]: {
...(currentTexts[activeWorkspaceKey] ?? {}),
[target.sectionId]: target.originalText
}
}))
}
type="button"
>
<RefreshCw size={14} />
Сбросить секцию
</button>
</div>
</div>
);
})
) : (
<small className="workspace-empty">Для этой страницы пока нет полей, связанных с замечаниями.</small>
)}
</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 (
<aside className="workspace-drawer open" key={`workspace-drawer-${activeWorkspaceDrawer}`}>
<div className="workspace-drawer-header">
<div>
<span>{isPagesDrawer ? "Страницы и секции" : "Ошибки страницы"}</span>
<strong>
{isPagesDrawer
? `${activeWorkspacePageIndex + 1}/${selectedWorkspaceScopeItems.length}`
: `${activeWorkspaceTargets.length} замечаний`}
</strong>
<small>
{isPagesDrawer
? "Переключает preview и ошибки рабочей зоны."
: "Карточки относятся к активной странице."}
</small>
</div>
<button
aria-label="Закрыть панель"
className="workspace-drawer-close"
onClick={() => closeWorkspaceDrawer(activeWorkspaceUiKey, activeWorkspaceDrawer)}
type="button"
>
<X size={16} />
</button>
</div>
{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
disabled={!target}
key={`workspace-issue-${issue.id}`}
className={isActivePage ? "active" : undefined}
disabled={isOpening}
key={`workspace-page-drawer-${scopeItem.scopeId}`}
onClick={() => {
if (!target) return;
activateWorkspaceTarget(activeWorkspaceKey, target);
void handleOpenPageWorkspace(project, scopeItem, { silent: true });
}}
type="button"
>
<span>{getAuditSeverityLabel(issue.severity)}</span>
<strong>{issue.message}</strong>
<small>{issue.field ? `Поле: ${issue.field}` : "Медиа/страница"}</small>
<p>{explanation.action}</p>
<span>{pageIndex + 1}</span>
<strong>{scopeItem.title}</strong>
<small>{scopeItem.pathLabel}</small>
<em>{pageIssues.length} замечаний</em>
</button>
);
})}
</div>
) : (
<small className="workspace-empty">По активной странице ошибок базового аудита нет.</small>
)}
</>
)}
</aside>
))}
<small className="workspace-page-menu-empty">
В выбранном наборе пока нет рабочих страниц или секций.
</small>
)
) : (
<div className="workspace-fields-drawer">
{activeWorkspaceTargets.length > 0 ? (
activeWorkspaceTargets.map((target) => {
const targetSectionText =
workspaceSectionTexts[activeWorkspaceKey]?.[target.sectionId] ?? target.workingText;
return (
<div
className={
activeWorkspaceTarget?.id === target.id
? "section-editor-card workspace-field-card active"
: "section-editor-card workspace-field-card"
}
id={getWorkspaceFieldDomId(activeWorkspaceKey, target.id)}
key={`workspace-field-${target.id}`}
>
<div className="workspace-subheading">
<strong>
{target.number}. {target.title}
</strong>
<small>{target.selector ?? "selector пока не задан"}</small>
</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">
<span>{getSectionKindLabel(target.kind)}</span>
<em>{target.workingText.length} симв.</em>
</div>
<label>
<span>Original · только чтение</span>
<pre>{target.originalText || "Пусто"}</pre>
</label>
<label>
<span>Working · редактируется</span>
<textarea
onChange={(event) =>
setWorkspaceSectionTexts((currentTexts) => ({
...currentTexts,
[activeWorkspaceKey]: {
...(currentTexts[activeWorkspaceKey] ?? {}),
[target.sectionId]: event.target.value
}
}))
}
value={targetSectionText}
/>
</label>
<div className="workspace-actions">
<button
className="tiny-action primary"
disabled={isWorkspaceBusy}
onClick={() =>
void handleSavePageWorkspace(
project.id,
activeWorkspace.pageId,
activeWorkspaceKey
)
}
type="button"
>
{isWorkspaceBusy ? <Loader2 className="spin" size={14} /> : <Save size={14} />}
Сохранить draft
</button>
<button
className="tiny-action"
disabled={isWorkspaceBusy}
onClick={() =>
setWorkspaceSectionTexts((currentTexts) => ({
...currentTexts,
[activeWorkspaceKey]: {
...(currentTexts[activeWorkspaceKey] ?? {}),
[target.sectionId]: target.originalText
}
}))
}
type="button"
>
<RefreshCw size={14} />
Сбросить секцию
</button>
</div>
</div>
);
})
) : (
<small className="workspace-empty">Для этой страницы пока нет ошибок, связанных с полями.</small>
)}
</div>
)}
</aside>
);
})}
</div>
) : null}
</div>

View File

@ -7926,3 +7926,137 @@ textarea:focus {
background: var(--nodedc-glass-control-bg) !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;
}