Fix SEO workspace scope preview

This commit is contained in:
DCCONSTRUCTIONS 2026-06-28 15:47:15 +03:00
parent d27e6ee43d
commit 1f140c1130
3 changed files with 1204 additions and 221 deletions

View File

@ -2,6 +2,7 @@ import {
Activity, Activity,
Archive, Archive,
CheckCircle2, CheckCircle2,
ChevronDown,
CircleAlert, CircleAlert,
Copy, Copy,
Database, Database,
@ -220,6 +221,10 @@ function getScanStatusLabel(status: string) {
} }
function getPageTitle(page: ProjectScanSummary["pages"][number]) { function getPageTitle(page: ProjectScanSummary["pages"][number]) {
if (page.scope === "template_block" && page.structureLabel) {
return page.structureLabel;
}
if (isHomePage(page)) { if (isHomePage(page)) {
return page.title || "Главная страница"; return page.title || "Главная страница";
} }
@ -263,6 +268,30 @@ function isIndexableScanPage(page: ProjectScanSummary["pages"][number]) {
return page.scope === "indexable_page"; return page.scope === "indexable_page";
} }
function isWorkspaceScopePage(page: ProjectScanSummary["pages"][number]) {
if (!page.selected) {
return false;
}
if (page.previewKind === "none" && !page.previewSourcePath && !page.previewTargetSelector) {
return false;
}
return true;
}
function getWorkspaceScopeOrder(page: ProjectScanSummary["pages"][number], originalIndex: number) {
if (isHomePage(page)) {
return -1;
}
if (page.structureOrder != null) {
return page.structureOrder;
}
return 1000 + originalIndex;
}
function getScanPageScopeLabel(scope: ProjectScanSummary["pages"][number]["scope"]) { function getScanPageScopeLabel(scope: ProjectScanSummary["pages"][number]["scope"]) {
const labels = { const labels = {
admin_page: "админка", admin_page: "админка",
@ -903,7 +932,11 @@ function getSelectedPagesCount(scan: ProjectScanSummary) {
} }
function getSelectedCoveragePages(scan: ProjectScanSummary) { function getSelectedCoveragePages(scan: ProjectScanSummary) {
return scan.pages.filter((page) => page.selected && isIndexableScanPage(page)); return scan.pages
.map((page, index) => ({ index, page }))
.filter(({ page }) => isWorkspaceScopePage(page))
.sort((left, right) => getWorkspaceScopeOrder(left.page, left.index) - getWorkspaceScopeOrder(right.page, right.index))
.map(({ page }) => page);
} }
function getWorkspaceKey(projectId: string, pageId: string) { function getWorkspaceKey(projectId: string, pageId: string) {
@ -930,6 +963,7 @@ type WorkspaceTarget = {
}; };
type WorkspacePreviewMode = "desktop" | "mobile"; type WorkspacePreviewMode = "desktop" | "mobile";
type WorkspaceDrawerPanel = "fields" | "issues" | "scope";
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" },
@ -1369,6 +1403,9 @@ export function App() {
const [activeWorkspaceSectionIds, setActiveWorkspaceSectionIds] = useState<Record<string, string>>({}); const [activeWorkspaceSectionIds, setActiveWorkspaceSectionIds] = useState<Record<string, string>>({});
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 [workspacePageMenus, setWorkspacePageMenus] = useState<Record<string, boolean>>({});
const [workspacePreviewPageIds, setWorkspacePreviewPageIds] = 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>>({});
const [workspaceSectionTexts, setWorkspaceSectionTexts] = useState<Record<string, WorkspaceSectionTextMap>>({}); const [workspaceSectionTexts, setWorkspaceSectionTexts] = useState<Record<string, WorkspaceSectionTextMap>>({});
@ -1557,27 +1594,51 @@ export function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
const timers: number[] = []; if (activeStageIndex !== 2) {
return;
for (const [workspaceKey, workspace] of Object.entries(pageWorkspaces)) {
const [projectId = "", pageId = ""] = workspaceKey.split(":");
const scanSummary = scanSummaries[projectId];
const issues = getPageIssues(scanSummary, pageId);
const targets = buildWorkspaceTargets(workspace, issues);
if (targets.length === 0) {
continue;
}
timers.push(window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets), 180));
timers.push(window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets), 700));
timers.push(window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets), 1600));
} }
const scrollToWorkspace = () => {
document.querySelector(".page-workspace-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const firstTimer = window.setTimeout(scrollToWorkspace, 120);
const secondTimer = window.setTimeout(scrollToWorkspace, 650);
return () => {
window.clearTimeout(firstTimer);
window.clearTimeout(secondTimer);
};
}, [activeProjectId, activeStageIndex]);
useEffect(() => {
const timers: number[] = [];
const projectId = activeProjectId ?? "";
const scanSummary = projectId ? scanSummaries[projectId] : null;
if (activeStageIndex !== 2 || !projectId || !scanSummary) {
return () => undefined;
}
const scopePages = getSelectedCoveragePages(scanSummary);
const activePageId = activeWorkspacePageIds[projectId] ?? scopePages[0]?.pageId ?? "";
const workspaceKey = activePageId ? getWorkspaceKey(projectId, activePageId) : "";
const workspace = workspaceKey ? pageWorkspaces[workspaceKey] : null;
if (!workspaceKey || !workspace) {
return () => undefined;
}
const issues = getPageIssues(scanSummary, activePageId);
const targets = buildWorkspaceTargets(workspace, issues);
timers.push(window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets, scopePages), 80));
timers.push(window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets, scopePages), 360));
timers.push(window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets, scopePages), 1000));
return () => { return () => {
timers.forEach((timer) => window.clearTimeout(timer)); timers.forEach((timer) => window.clearTimeout(timer));
}; };
}, [activeStageIndex, activeWorkspacePageIds, pageWorkspaces, scanSummaries, workspacePreviewModes]); }, [activeProjectId, activeStageIndex, activeWorkspacePageIds, pageWorkspaces, scanSummaries, workspacePreviewModes]);
function scrollWorkspaceFieldIntoView(workspaceKey: string, targetId: string, attempt = 0) { function scrollWorkspaceFieldIntoView(workspaceKey: string, targetId: string, attempt = 0) {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@ -1599,6 +1660,46 @@ export function App() {
const data = event.data; const data = event.data;
if (!data || typeof data !== "object" || data.type !== "seo-mode:preview-markers") { if (!data || typeof data !== "object" || data.type !== "seo-mode:preview-markers") {
if (data?.type === "seo-mode:active-scope-page") {
const sourceWorkspaceKey = typeof data.workspaceKey === "string" ? data.workspaceKey : "";
const pageId = typeof data.pageId === "string" ? data.pageId : "";
const [projectId = ""] = sourceWorkspaceKey.split(":");
if (!projectId || !pageId) {
return;
}
const nextWorkspaceKey = getWorkspaceKey(projectId, pageId);
if (!pageWorkspaces[nextWorkspaceKey]) {
const project = projects.find((item) => item.id === projectId);
const scanSummary = scanSummaries[projectId];
const page = scanSummary ? getSelectedCoveragePages(scanSummary).find((item) => item.pageId === pageId) : null;
if (project && page && busyWorkspaceKey !== nextWorkspaceKey) {
void handleOpenPageWorkspace(project, page, {
deferActiveUntilLoaded: true,
navigatePreview: false,
silent: true
});
}
return;
}
setActiveWorkspacePageIds((currentPageIds) => {
if (currentPageIds[projectId] === pageId) {
return currentPageIds;
}
return {
...currentPageIds,
[projectId]: pageId
};
});
return;
}
if (data?.type !== "seo-mode:activate-target") { if (data?.type !== "seo-mode:activate-target") {
return; return;
} }
@ -1619,6 +1720,7 @@ export function App() {
...currentSectionIds, ...currentSectionIds,
[workspaceKey]: sectionId || targetId [workspaceKey]: sectionId || targetId
})); }));
openWorkspaceDrawer(workspaceKey, "fields");
scrollWorkspaceFieldIntoView(workspaceKey, targetId); scrollWorkspaceFieldIntoView(workspaceKey, targetId);
return; return;
@ -1669,7 +1771,7 @@ export function App() {
return () => { return () => {
window.removeEventListener("message", handlePreviewMessage); window.removeEventListener("message", handlePreviewMessage);
}; };
}, []); }, [busyWorkspaceKey, pageWorkspaces, projects, scanSummaries]);
useEffect(() => { useEffect(() => {
if (projects.length === 0) { if (projects.length === 0) {
@ -2166,7 +2268,7 @@ export function App() {
async function handleOpenPageWorkspace( async function handleOpenPageWorkspace(
project: ProjectSummary, project: ProjectSummary,
page: ProjectScanSummary["pages"][number], page: ProjectScanSummary["pages"][number],
options: { silent?: boolean } = {} options: { deferActiveUntilLoaded?: boolean; navigatePreview?: boolean; silent?: boolean } = {}
) { ) {
const workspaceKey = getWorkspaceKey(project.id, page.pageId); const workspaceKey = getWorkspaceKey(project.id, page.pageId);
@ -2178,10 +2280,19 @@ export function App() {
setProjectActionProjectId(project.id); setProjectActionProjectId(project.id);
} }
setActiveWorkspacePageIds((currentPageIds) => ({ if (options.navigatePreview !== false) {
...currentPageIds, setWorkspacePreviewPageIds((currentPageIds) => ({
[project.id]: page.pageId ...currentPageIds,
})); [project.id]: page.pageId
}));
}
if (!options.deferActiveUntilLoaded) {
setActiveWorkspacePageIds((currentPageIds) => ({
...currentPageIds,
[project.id]: page.pageId
}));
}
try { try {
const result = await openPageWorkspace(project.id, page.pageId); const result = await openPageWorkspace(project.id, page.pageId);
@ -2206,6 +2317,12 @@ export function App() {
...currentTargetIds, ...currentTargetIds,
[workspaceKey]: currentTargetIds[workspaceKey] ?? result.workspace.sections[0]?.id ?? "" [workspaceKey]: currentTargetIds[workspaceKey] ?? result.workspace.sections[0]?.id ?? ""
})); }));
if (options.deferActiveUntilLoaded) {
setActiveWorkspacePageIds((currentPageIds) => ({
...currentPageIds,
[project.id]: page.pageId
}));
}
if (!options.silent) { if (!options.silent) {
setProjectActionMessage(`Рабочая зона открыта: ${getPageTitle(page)}.`); setProjectActionMessage(`Рабочая зона открыта: ${getPageTitle(page)}.`);
@ -2320,11 +2437,67 @@ export function App() {
...currentSectionIds, ...currentSectionIds,
[workspaceKey]: target.sectionId [workspaceKey]: target.sectionId
})); }));
openWorkspaceDrawer(workspaceKey, "fields");
scrollWorkspaceFieldIntoView(workspaceKey, target.id); scrollWorkspaceFieldIntoView(workspaceKey, target.id);
} }
function updateWorkspacePreviewMarkers(workspaceKey: string, targets: WorkspaceTarget[]) { function openWorkspaceDrawer(workspaceKey: string, drawer: WorkspaceDrawerPanel) {
setWorkspaceDrawers((currentDrawers) => {
const currentWorkspaceDrawers = currentDrawers[workspaceKey] ?? [];
if (currentWorkspaceDrawers.includes(drawer)) {
return currentDrawers;
}
return {
...currentDrawers,
[workspaceKey]: [...currentWorkspaceDrawers, drawer]
};
});
}
function toggleWorkspaceDrawer(workspaceKey: string, drawer: WorkspaceDrawerPanel) {
setWorkspaceDrawers((currentDrawers) => {
const currentWorkspaceDrawers = currentDrawers[workspaceKey] ?? [];
const nextWorkspaceDrawers = currentWorkspaceDrawers.includes(drawer)
? currentWorkspaceDrawers.filter((currentDrawer) => currentDrawer !== drawer)
: [...currentWorkspaceDrawers, drawer];
return {
...currentDrawers,
[workspaceKey]: nextWorkspaceDrawers
};
});
}
function closeWorkspaceDrawer(workspaceKey: string, drawer: WorkspaceDrawerPanel) {
setWorkspaceDrawers((currentDrawers) => {
const currentWorkspaceDrawers = currentDrawers[workspaceKey] ?? [];
if (!currentWorkspaceDrawers.includes(drawer)) {
return currentDrawers;
}
return {
...currentDrawers,
[workspaceKey]: currentWorkspaceDrawers.filter((currentDrawer) => currentDrawer !== drawer)
};
});
}
function toggleWorkspacePageMenu(workspaceKey: string) {
setWorkspacePageMenus((currentMenus) => ({
...currentMenus,
[workspaceKey]: !currentMenus[workspaceKey]
}));
}
function updateWorkspacePreviewMarkers(
workspaceKey: string,
targets: WorkspaceTarget[],
scopePages: ProjectScanSummary["pages"] = []
) {
const frameWindow = previewFrameRefs.current[workspaceKey]?.contentWindow; const frameWindow = previewFrameRefs.current[workspaceKey]?.contentWindow;
if (!frameWindow) { if (!frameWindow) {
@ -2340,6 +2513,14 @@ export function App() {
{ {
type: "seo-mode:measure-targets", type: "seo-mode:measure-targets",
workspaceKey, workspaceKey,
activePageId: workspaceKey.split(":")[1] ?? "",
scopePages: scopePages
.filter((page) => page.previewTargetSelector)
.map((page) => ({
pageId: page.pageId,
label: getPageTitle(page),
selector: page.previewTargetSelector
})),
targets: targets targets: targets
.filter((target) => (target.markerSelectors.length > 0 || target.markerFallbackSelector) && target.issues.length > 0) .filter((target) => (target.markerSelectors.length > 0 || target.markerFallbackSelector) && target.issues.length > 0)
.map((target) => ({ .map((target) => ({
@ -2358,20 +2539,29 @@ export function App() {
); );
} }
function changeWorkspacePreviewMode(workspaceKey: string, mode: WorkspacePreviewMode, targets: WorkspaceTarget[]) { function changeWorkspacePreviewMode(
workspaceKey: string,
mode: WorkspacePreviewMode,
targets: WorkspaceTarget[],
scopePages: ProjectScanSummary["pages"] = []
) {
setWorkspacePreviewModes((currentModes) => ({ setWorkspacePreviewModes((currentModes) => ({
...currentModes, ...currentModes,
[workspaceKey]: mode [workspaceKey]: mode
})); }));
window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets), 120); window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets, scopePages), 120);
window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets), 450); window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets, scopePages), 450);
} }
function handleWorkspacePreviewLoad(workspaceKey: string, targets: WorkspaceTarget[]) { function handleWorkspacePreviewLoad(
updateWorkspacePreviewMarkers(workspaceKey, targets); workspaceKey: string,
window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets), 350); targets: WorkspaceTarget[],
window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets), 1000); scopePages: ProjectScanSummary["pages"] = []
) {
updateWorkspacePreviewMarkers(workspaceKey, targets, scopePages);
window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets, scopePages), 350);
window.setTimeout(() => updateWorkspacePreviewMarkers(workspaceKey, targets, scopePages), 1000);
} }
function setAuditIssuesVisibleCount(scanId: string, visibleCount: number) { function setAuditIssuesVisibleCount(scanId: string, visibleCount: number) {
@ -2429,6 +2619,11 @@ export function App() {
delete nextPageIds[project.id]; delete nextPageIds[project.id];
return nextPageIds; return nextPageIds;
}); });
setWorkspacePreviewPageIds((currentPageIds) => {
const nextPageIds = { ...currentPageIds };
delete nextPageIds[project.id];
return nextPageIds;
});
setPageWorkspaces((currentWorkspaces) => setPageWorkspaces((currentWorkspaces) =>
Object.fromEntries( Object.fromEntries(
Object.entries(currentWorkspaces).filter(([workspaceKey]) => !workspaceKey.startsWith(`${project.id}:`)) Object.entries(currentWorkspaces).filter(([workspaceKey]) => !workspaceKey.startsWith(`${project.id}:`))
@ -2440,6 +2635,14 @@ export function App() {
setWorkspaceSectionTexts((currentTexts) => setWorkspaceSectionTexts((currentTexts) =>
Object.fromEntries(Object.entries(currentTexts).filter(([workspaceKey]) => !workspaceKey.startsWith(`${project.id}:`))) Object.fromEntries(Object.entries(currentTexts).filter(([workspaceKey]) => !workspaceKey.startsWith(`${project.id}:`)))
); );
setWorkspaceDrawers((currentDrawers) =>
Object.fromEntries(
Object.entries(currentDrawers).filter(([workspaceKey]) => !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(
Object.entries(currentSectionIds).filter(([workspaceKey]) => !workspaceKey.startsWith(`${project.id}:`)) Object.entries(currentSectionIds).filter(([workspaceKey]) => !workspaceKey.startsWith(`${project.id}:`))
@ -2516,24 +2719,30 @@ export function App() {
continue; continue;
} }
const selectedPage = scanSummary.pages.find((page) => page.selected); const selectedWorkspacePages = getSelectedCoveragePages(scanSummary);
const selectedPage = selectedWorkspacePages[0];
if (!selectedPage) { if (!selectedPage) {
continue; continue;
} }
const activePageId = activeWorkspacePageIds[project.id] ?? selectedPage.pageId; const activePageId = activeWorkspacePageIds[project.id] ?? selectedPage.pageId;
const pageToOpen = scanSummary.pages.find((page) => page.pageId === activePageId) ?? selectedPage; const pageToOpen =
selectedWorkspacePages.find((page) => page.pageId === activePageId) ??
selectedPage;
const workspaceKey = getWorkspaceKey(project.id, pageToOpen.pageId); const workspaceKey = getWorkspaceKey(project.id, pageToOpen.pageId);
if (pageWorkspaces[workspaceKey] || busyWorkspaceKey === workspaceKey) { if (pageWorkspaces[workspaceKey] || busyWorkspaceKey === workspaceKey) {
continue; continue;
} }
void handleOpenPageWorkspace(project, pageToOpen, { silent: true }); void handleOpenPageWorkspace(project, pageToOpen, {
navigatePreview: !workspacePreviewPageIds[project.id],
silent: true
});
break; break;
} }
}, [activeWorkspacePageIds, busyWorkspaceKey, pageWorkspaces, projects, scanSummaries]); }, [activeWorkspacePageIds, busyWorkspaceKey, pageWorkspaces, projects, scanSummaries, workspacePreviewPageIds]);
const createNameConflict = pickedFolder ? hasProjectNameConflict(pickedFolder.rootName) : false; const createNameConflict = pickedFolder ? hasProjectNameConflict(pickedFolder.rootName) : false;
const canCreateProject = Boolean(pickedFolder) && !isSubmitting && !createNameConflict; const canCreateProject = Boolean(pickedFolder) && !isSubmitting && !createNameConflict;
@ -3144,19 +3353,26 @@ 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 activeWorkspaceSectionId = const activeWorkspaceDrawers = activeWorkspaceKey ? (workspaceDrawers[activeWorkspaceKey] ?? []) : [];
activeWorkspaceKey && activeWorkspace const isWorkspacePageMenuOpen = activeWorkspaceKey ? Boolean(workspacePageMenus[activeWorkspaceKey]) : false;
? (activeWorkspaceTarget?.sectionId ?? activeWorkspaceSectionIds[activeWorkspaceKey] ?? activeWorkspace.sections[0]?.id ?? "") const previewWorkspacePageId = workspacePreviewPageIds[project.id] ?? activeWorkspacePageId;
: ""; const previewWorkspacePage =
const activeWorkspaceSection = selectedCoveragePages.find((page) => page.pageId === previewWorkspacePageId) ??
activeWorkspace?.sections.find((section) => section.id === activeWorkspaceSectionId) ?? activeWorkspacePage;
activeWorkspace?.sections[0] ?? const activeWorkspacePageIndex = Math.max(
null; 0,
const activeWorkspaceSectionText = selectedCoveragePages.findIndex((page) => page.pageId === activeWorkspacePageId)
activeWorkspaceKey && activeWorkspaceSection );
? (workspaceSectionTexts[activeWorkspaceKey]?.[activeWorkspaceSection.id] ?? const activeWorkspacePreviewSourcePath =
activeWorkspaceSection.workingText) activeWorkspace && previewWorkspacePage
? (previewWorkspacePage.previewSourcePath ?? previewWorkspacePage.sourcePath)
: ""; : "";
const activeWorkspacePreviewFocusSelector = previewWorkspacePage?.previewTargetSelector ?? null;
const activeWorkspaceIssueCounts = getAuditIssueCounts(activeWorkspaceIssues);
const selectedCoverageIssueCount = selectedCoveragePages.reduce(
(total, page) => total + getPageIssues(scanSummary, page.pageId).length,
0
);
const selectedAuditIssuesAll = scanSummary?.audit const selectedAuditIssuesAll = scanSummary?.audit
? getSelectedAuditIssues(scanSummary, scanSummary.audit.issues) ? getSelectedAuditIssues(scanSummary, scanSummary.audit.issues)
: []; : [];
@ -4362,7 +4578,7 @@ export function App() {
<section className="page-workspace-panel"> <section className="page-workspace-panel">
<div className="page-workspace-heading"> <div className="page-workspace-heading">
<div> <div>
<strong>Рабочая зона выбранных посадочных</strong> <strong>Рабочая зона выбранного scope</strong>
<small>Original read-only, working draft сохраняется в Postgres + MinIO.</small> <small>Original read-only, working draft сохраняется в Postgres + MinIO.</small>
</div> </div>
<span>Apply пока не трогаем</span> <span>Apply пока не трогаем</span>
@ -4388,41 +4604,157 @@ export function App() {
})} })}
</div> </div>
{activeWorkspace && activeWorkspacePage && activeWorkspaceKey ? ( {activeWorkspace && activeWorkspacePage && activeWorkspaceKey ? (
<div className="workspace-editor-stack"> <div className="workspace-editor-stack workspace-cockpit">
<div className="workspace-current-page"> <div className="workspace-current-page">
<div> <div>
<span>Выбранная страница</span> <span>
Активная страница {activeWorkspacePageIndex + 1}/{selectedCoveragePages.length} · выбранный scope
</span>
<strong>{getPageTitle(activeWorkspacePage)}</strong> <strong>{getPageTitle(activeWorkspacePage)}</strong>
<small>{getPagePathLabel(activeWorkspacePage)}</small> <small>{getPagePathLabel(activeWorkspacePage)}</small>
</div> </div>
<em>{activeWorkspaceIssues.length} замечаний</em>
</div> </div>
<div className="workspace-preview-layout"> <section className={`workspace-preview-panel workspace-cockpit-panel ${activeWorkspaceDrawers.length > 0 ? "drawer-open" : ""}`}>
<section className="workspace-preview-panel"> <div className="workspace-cockpit-toolbar">
<div className="workspace-subheading"> <div className="workspace-preview-status">
<strong>Зеркало страницы</strong> <span>Замечаний на активной странице: {activeWorkspaceIssues.length}</span>
<small>Оранжевые точки ведут к полям правки ниже.</small> <span>В выбранном scope: {selectedCoverageIssueCount}</span>
<span>Warning: {activeWorkspaceIssueCounts.warnings} · Info: {activeWorkspaceIssueCounts.info}</span>
</div> </div>
<div className="workspace-preview-mode-switch" aria-label="Режим зеркала"> <div className="workspace-current-page-actions">
{WORKSPACE_PREVIEW_MODES.map((mode) => ( <div className={`workspace-page-control ${isWorkspacePageMenuOpen ? "open" : ""}`}>
<button <button
className={activeWorkspacePreviewMode === mode.id ? "active" : undefined} aria-expanded={isWorkspacePageMenuOpen}
key={mode.id} aria-haspopup="menu"
onClick={() => changeWorkspacePreviewMode(activeWorkspaceKey, mode.id, activeWorkspaceTargets)} className="workspace-page-control-trigger"
onClick={() => toggleWorkspacePageMenu(activeWorkspaceKey)}
type="button" type="button"
> >
{mode.label} <FileText size={14} />
Страница
<em>
{activeWorkspacePageIndex + 1}/{selectedCoveragePages.length}
</em>
<ChevronDown size={14} />
</button> </button>
))} {isWorkspacePageMenuOpen ? (
<div className="workspace-page-menu" role="menu">
<div className="workspace-page-menu-head">
<strong>Страницы и секции scope</strong>
<small>Переключает preview, ошибки и поля рабочей зоны.</small>
</div>
{selectedCoveragePages.length > 0 ? (
<div className="workspace-page-menu-list">
{selectedCoveragePages.map((page, pageIndex) => {
const pageWorkspaceKey = getWorkspaceKey(project.id, page.pageId);
const isActivePage = activeWorkspacePageId === page.pageId;
const isOpening = busyWorkspaceKey === pageWorkspaceKey;
const pageIssues = getPageIssues(scanSummary, page.pageId);
return (
<button
className={isActivePage ? "active" : undefined}
disabled={isOpening}
key={`workspace-page-menu-${page.pageId}`}
onClick={() => {
setWorkspacePageMenus((currentMenus) => ({
...currentMenus,
[activeWorkspaceKey]: false
}));
void handleOpenPageWorkspace(project, page, { silent: true });
}}
role="menuitem"
type="button"
>
<span>{pageIndex + 1}</span>
<strong>{getPageTitle(page)}</strong>
<small>{getPagePathLabel(page)}</small>
<em>{pageIssues.length} замечаний</em>
</button>
);
})}
</div>
) : (
<small className="workspace-page-menu-empty">
В выбранном scope пока нет рабочих страниц или секций.
</small>
)}
</div>
) : null}
</div>
<button
className={activeWorkspaceDrawers.includes("scope") ? "active" : undefined}
onClick={() => toggleWorkspaceDrawer(activeWorkspaceKey, "scope")}
type="button"
>
<FileText size={14} />
Scope
<em>{selectedCoveragePages.length}</em>
</button>
<button
className={activeWorkspaceDrawers.includes("issues") ? "active" : undefined}
onClick={() => toggleWorkspaceDrawer(activeWorkspaceKey, "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="Режим зеркала">
{WORKSPACE_PREVIEW_MODES.map((mode) => (
<button
className={activeWorkspacePreviewMode === mode.id ? "active" : undefined}
key={mode.id}
onClick={() =>
changeWorkspacePreviewMode(
activeWorkspaceKey,
mode.id,
activeWorkspaceTargets,
selectedCoveragePages
)
}
type="button"
>
{mode.label}
</button>
))}
</div>
</div> </div>
</div>
<div className="workspace-preview-shell">
<div className={`workspace-preview-frame ${activeWorkspacePreviewMode === "mobile" ? "mobile-preview" : "desktop-preview"}`}> <div className={`workspace-preview-frame ${activeWorkspacePreviewMode === "mobile" ? "mobile-preview" : "desktop-preview"}`}>
<iframe <iframe
onLoad={() => handleWorkspacePreviewLoad(activeWorkspaceKey, activeWorkspaceTargets)} onLoad={() =>
handleWorkspacePreviewLoad(
activeWorkspaceKey,
activeWorkspaceTargets,
selectedCoveragePages
)
}
ref={(element) => { ref={(element) => {
previewFrameRefs.current[activeWorkspaceKey] = element; if (element) {
previewFrameRefs.current = {
[activeWorkspaceKey]: element
};
} else {
delete previewFrameRefs.current[activeWorkspaceKey];
}
}} }}
sandbox="allow-scripts allow-forms allow-popups" sandbox="allow-scripts allow-forms allow-popups"
src={getPreviewPageUrl(project.id, activeWorkspace.page.sourcePath)} src={getPreviewPageUrl(
project.id,
activeWorkspacePreviewSourcePath,
activeWorkspacePreviewFocusSelector
)}
title={`Предпросмотр ${getPageTitle(activeWorkspacePage)}`} title={`Предпросмотр ${getPageTitle(activeWorkspacePage)}`}
/> />
<div className="workspace-preview-markers"> <div className="workspace-preview-markers">
@ -4449,142 +4781,192 @@ export function App() {
})} })}
</div> </div>
</div> </div>
</section> {activeWorkspaceDrawers.length > 0 ? (
<section className="workspace-page-issues"> <div className="workspace-drawer-stack">
<div className="workspace-subheading"> {activeWorkspaceDrawers.map((activeWorkspaceDrawer) => (
<strong>Ошибки этой страницы</strong> <aside className="workspace-drawer open" key={`workspace-drawer-${activeWorkspaceDrawer}`}>
<small>{activeWorkspaceIssues.length} шт. из базового аудита</small> <div className="workspace-drawer-header">
</div> <div>
{activeWorkspaceIssues.length > 0 ? ( <span>
<div className="workspace-issue-list"> {activeWorkspaceDrawer === "scope"
{activeWorkspaceIssues.map((issue) => { ? "Выбранный scope"
const target = activeWorkspaceTargets.find((item) => item.issues.some((targetIssue) => targetIssue.id === issue.id)); : activeWorkspaceDrawer === "fields"
? "Поля для правки"
: "Аудит страницы"}
</span>
<strong>
{activeWorkspaceDrawer === "scope"
? `${selectedCoveragePages.length} в scope`
: activeWorkspaceDrawer === "fields"
? `${activeWorkspaceTargets.length} полей`
: `${activeWorkspaceIssues.length} замечаний`}
</strong>
<small>
{activeWorkspaceDrawer === "scope"
? "Дальше анализируются только выбранные страницы и секции."
: activeWorkspaceDrawer === "fields"
? "Клик по точке или карточке открывает связанное поле."
: "Список относится к активной странице, не ко всему сайту."}
</small>
</div>
<button
aria-label="Закрыть панель"
className="workspace-drawer-close"
onClick={() => closeWorkspaceDrawer(activeWorkspaceKey, activeWorkspaceDrawer)}
type="button"
>
<X size={16} />
</button>
</div>
{activeWorkspaceDrawer === "scope" ? (
<div className="workspace-scope-list">
{selectedCoveragePages.map((page, pageIndex) => {
const pageWorkspaceKey = getWorkspaceKey(project.id, page.pageId);
const isOpening = busyWorkspaceKey === pageWorkspaceKey;
const isActivePage = activeWorkspacePageId === page.pageId;
const pageIssues = getPageIssues(scanSummary, page.pageId);
return ( return (
<button <button
disabled={!target} className={isActivePage ? "active" : undefined}
key={`workspace-issue-${issue.id}`} disabled={isOpening}
onClick={() => { key={`workspace-scope-${page.pageId}`}
if (!target) return; onClick={() => void handleOpenPageWorkspace(project, page)}
activateWorkspaceTarget(activeWorkspaceKey, target); type="button"
}} >
type="button" <span>{pageIndex + 1}</span>
> <strong>{getPageTitle(page)}</strong>
<span>{getAuditSeverityLabel(issue.severity)}</span> <small>{getPagePathLabel(page)}</small>
<strong>{issue.message}</strong> <em>{pageIssues.length} замечаний</em>
<small>{issue.field ? `Поле: ${issue.field}` : "Медиа/страница"}</small> </button>
</button> );
); })}
})} </div>
</div> ) : activeWorkspaceDrawer === "fields" ? (
) : ( <div className="workspace-fields-drawer">
<small className="workspace-empty">По этой странице ошибок аудита нет.</small> {activeWorkspaceTargets.length > 0 ? (
)} activeWorkspaceTargets.map((target) => {
</section> const targetSectionText =
</div> workspaceSectionTexts[activeWorkspaceKey]?.[target.sectionId] ?? target.workingText;
<section className="workspace-section-workbench">
<div className="workspace-subheading"> return (
<strong>Поля для правки</strong> <div
<small>Title, meta, заголовки, текстовые блоки и media-поля редактируются здесь.</small> className={
</div> activeWorkspaceTarget?.id === target.id
<div className="section-workbench-grid"> ? "section-editor-card workspace-field-card active"
<div className="section-map-list"> : "section-editor-card workspace-field-card"
{activeWorkspaceTargets.map((target) => ( }
<button id={getWorkspaceFieldDomId(activeWorkspaceKey, target.id)}
className={ key={`workspace-field-${target.id}`}
activeWorkspaceTarget?.id === target.id ? "section-map-card workspace-target-card active" : "section-map-card workspace-target-card" >
} <div className="workspace-subheading">
key={target.id} <strong>
onClick={() => activateWorkspaceTarget(activeWorkspaceKey, target)} {target.number}. {target.title}
type="button" </strong>
> <small>{target.selector ?? "selector пока не задан"}</small>
<div> </div>
<span>{target.number}</span> <div className="workspace-field-meta">
<small>{getSectionKindLabel(target.kind)}</small> <span>{getSectionKindLabel(target.kind)}</span>
</div> <em>{target.workingText.length} симв.</em>
<strong>{target.title}</strong> </div>
<p>{target.issues[0]?.message ?? target.subtitle}</p> {target.issues.length > 0 ? (
<em>{target.workingText.length} симв.</em> <div className="workspace-target-issues">
</button> {target.issues.map((issue) => (
))} <small key={`target-issue-${target.id}-${issue.id}`}>
</div> {getAuditSeverityLabel(issue.severity)} · {issue.message}
{activeWorkspaceTarget ? ( </small>
<div className="section-editor-card" id={getWorkspaceFieldDomId(activeWorkspaceKey, activeWorkspaceTarget.id)}> ))}
<div className="workspace-subheading"> </div>
<strong> ) : null}
{activeWorkspaceTarget.number}. {activeWorkspaceTarget.title} <label>
</strong> <span>Original · только чтение</span>
<small>{activeWorkspaceTarget.selector ?? "selector пока не задан"}</small> <pre>{target.originalText || "Пусто"}</pre>
</div> </label>
{activeWorkspaceTarget.issues.length > 0 ? ( <label>
<div className="workspace-target-issues"> <span>Working · редактируется</span>
{activeWorkspaceTarget.issues.map((issue) => ( <textarea
<small key={`target-issue-${issue.id}`}> onChange={(event) =>
{getAuditSeverityLabel(issue.severity)} · {issue.message} setWorkspaceSectionTexts((currentTexts) => ({
</small> ...currentTexts,
))} [activeWorkspaceKey]: {
</div> ...(currentTexts[activeWorkspaceKey] ?? {}),
) : null} [target.sectionId]: event.target.value
<label> }
<span>Original · только чтение</span> }))
<pre>{activeWorkspaceTarget.originalText || "Пусто"}</pre> }
</label> value={targetSectionText}
<label> />
<span>Working · редактируется</span> </label>
<textarea <div className="workspace-actions">
onChange={(event) => <button
setWorkspaceSectionTexts((currentTexts) => ({ className="tiny-action primary"
...currentTexts, disabled={isWorkspaceBusy}
[activeWorkspaceKey]: { onClick={() =>
...(currentTexts[activeWorkspaceKey] ?? {}), void handleSavePageWorkspace(project.id, activeWorkspace.pageId)
[activeWorkspaceTarget.sectionId]: event.target.value }
} type="button"
})) >
} {isWorkspaceBusy ? <Loader2 className="spin" size={14} /> : <Save size={14} />}
value={activeWorkspaceSectionText} Сохранить draft
/> </button>
</label> <button
<div className="workspace-actions"> className="tiny-action"
<button disabled={isWorkspaceBusy}
className="tiny-action primary" onClick={() =>
disabled={isWorkspaceBusy} setWorkspaceSectionTexts((currentTexts) => ({
onClick={() => ...currentTexts,
void handleSavePageWorkspace(project.id, activeWorkspace.pageId) [activeWorkspaceKey]: {
} ...(currentTexts[activeWorkspaceKey] ?? {}),
type="button" [target.sectionId]: target.originalText
> }
{isWorkspaceBusy ? <Loader2 className="spin" size={14} /> : <Save size={14} />} }))
Сохранить draft }
</button> type="button"
<button >
className="tiny-action" <RefreshCw size={14} />
disabled={isWorkspaceBusy} Сбросить секцию
onClick={() => </button>
setWorkspaceSectionTexts((currentTexts) => ({ </div>
...currentTexts, </div>
[activeWorkspaceKey]: { );
...(currentTexts[activeWorkspaceKey] ?? {}), })
[activeWorkspaceTarget.sectionId]: activeWorkspaceTarget.originalText ) : (
} <small className="workspace-empty">Для этой страницы пока нет полей, связанных с замечаниями.</small>
})) )}
} </div>
type="button" ) : (
> <>
<RefreshCw size={14} /> {activeWorkspaceIssues.length > 0 ? (
Сбросить секцию <div className="workspace-issue-list">
</button> {activeWorkspaceIssues.map((issue) => {
<button const target = activeWorkspaceTargets.find((item) => item.issues.some((targetIssue) => targetIssue.id === issue.id));
className="tiny-action" const explanation = getAuditIssueExplanation(issue.code);
disabled={isWorkspaceBusy}
onClick={() => return (
void handleResetPageWorkspace(project.id, activeWorkspace.pageId) <button
} disabled={!target}
type="button" key={`workspace-issue-${issue.id}`}
> onClick={() => {
<RefreshCw size={14} /> if (!target) return;
Сбросить весь draft activateWorkspaceTarget(activeWorkspaceKey, target);
</button> }}
</div> 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>
))}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -6317,7 +6317,7 @@ input[type="checkbox"] {
} }
} }
/* Stage 3: keep the page editor clean. The selected page is the object now, /* Stage 3: page workspace cockpit. The selected page is the object now,
so project/list scaffolding stays out of the visual hierarchy. */ so project/list scaffolding stays out of the visual hierarchy. */
.stage-3 .project-row { .stage-3 .project-row {
padding: 0 !important; padding: 0 !important;
@ -6327,6 +6327,26 @@ input[type="checkbox"] {
box-shadow: none !important; box-shadow: none !important;
} }
.stage-3 .project-setup {
gap: 0 !important;
}
.stage-3 .project-setup > .section-heading {
display: none !important;
}
.stage-3 .project-list-card {
padding: 0 !important;
background: transparent !important;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.stage-3 .project-list {
gap: 0 !important;
}
.stage-3 .project-row > div > .project-title-row, .stage-3 .project-row > div > .project-title-row,
.stage-3 .project-row > div > .project-source, .stage-3 .project-row > div > .project-source,
.stage-3 .page-workspace-heading, .stage-3 .page-workspace-heading,
@ -6344,18 +6364,16 @@ input[type="checkbox"] {
} }
.stage-3 .workspace-editor-stack { .stage-3 .workspace-editor-stack {
gap: 18px !important; gap: 14px !important;
} }
.workspace-current-page { .workspace-current-page {
display: flex; display: grid;
align-items: flex-end; gap: 4px;
justify-content: space-between;
gap: 18px;
padding: 0 4px; padding: 0 4px;
} }
.workspace-current-page div { .workspace-current-page > div {
display: grid; display: grid;
gap: 4px; gap: 4px;
min-width: 0; min-width: 0;
@ -6371,9 +6389,11 @@ input[type="checkbox"] {
color: var(--ink); color: var(--ink);
font-size: clamp(24px, 2.2vw, 34px); font-size: clamp(24px, 2.2vw, 34px);
font-weight: 760; font-weight: 760;
letter-spacing: -0.04em; letter-spacing: 0;
line-height: 1.05; line-height: 1.05;
overflow-wrap: anywhere; max-width: 100%;
overflow-wrap: normal;
word-break: normal;
} }
.workspace-current-page small { .workspace-current-page small {
@ -6383,16 +6403,211 @@ input[type="checkbox"] {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.workspace-current-page em { .workspace-cockpit-toolbar {
flex: 0 0 auto; display: flex;
padding: 8px 12px; align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding: 0 2px;
}
.workspace-current-page-actions {
position: relative;
z-index: 10;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.workspace-current-page-actions > button,
.workspace-page-control-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
padding: 0 13px;
color: var(--ink); color: var(--ink);
background: #f2f2f4; background: #fff;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
cursor: pointer;
font-size: 13px; font-size: 13px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.workspace-current-page-actions > button:hover,
.workspace-page-control-trigger:hover {
border-color: rgba(24, 32, 29, 0.2);
background: #fafafa;
}
.workspace-current-page-actions > button.active,
.workspace-page-control.open .workspace-page-control-trigger {
color: #fff;
background: var(--ink);
border-color: var(--ink);
}
.workspace-current-page-actions > button em,
.workspace-page-control-trigger em {
flex: 0 0 auto;
min-width: 23px;
height: 23px;
display: inline-grid;
place-items: center;
padding: 0 7px;
color: currentColor;
background: rgba(24, 32, 29, 0.06);
border-radius: 999px;
font-size: 12px;
font-style: normal; font-style: normal;
font-weight: 560; font-weight: 800;
}
.workspace-current-page-actions > button.active em,
.workspace-page-control.open .workspace-page-control-trigger em {
background: rgba(255, 255, 255, 0.16);
}
.workspace-page-control {
position: relative;
flex: 0 0 auto;
}
.workspace-page-control-trigger svg:last-child {
transition: transform 160ms ease;
}
.workspace-page-control.open .workspace-page-control-trigger svg:last-child {
transform: rotate(180deg);
}
.workspace-page-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
display: grid;
gap: 10px;
width: min(380px, calc(100vw - 48px));
max-height: min(420px, calc(100vh - 240px));
padding: 12px;
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.72)),
rgba(255, 255, 255, 0.94);
border: 1px solid rgba(24, 32, 29, 0.1);
border-radius: 20px;
box-shadow: 0 22px 70px rgba(24, 32, 29, 0.16);
backdrop-filter: blur(30px) saturate(1.08);
-webkit-backdrop-filter: blur(30px) saturate(1.08);
}
.workspace-page-menu-head {
display: grid;
gap: 3px;
padding: 2px 2px 0;
}
.workspace-page-menu-head strong {
color: var(--ink);
font-size: 14px;
font-weight: 780;
line-height: 1.1;
}
.workspace-page-menu-head small,
.workspace-page-menu-empty {
color: var(--muted);
font-size: 12px;
font-weight: 520;
line-height: 1.35;
}
.workspace-page-menu-list {
display: grid;
gap: 7px;
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.workspace-page-menu-list button {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 3px 9px;
width: 100%;
padding: 10px;
text-align: left;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--line);
border-radius: 14px;
cursor: pointer;
}
.workspace-page-menu-list button:hover,
.workspace-page-menu-list button.active {
background: #fffaf5;
border-color: rgba(242, 122, 26, 0.32);
}
.workspace-page-menu-list button span {
grid-row: span 2;
width: 26px;
height: 26px;
display: grid;
place-items: center;
color: #fff;
background: var(--accent);
border-radius: 999px;
font-size: 12px;
font-weight: 850;
}
.workspace-page-menu-list button strong,
.workspace-page-menu-list button small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-page-menu-list button strong {
color: var(--ink);
font-size: 13px;
font-weight: 760;
line-height: 1.15;
white-space: nowrap;
}
.workspace-page-menu-list button small {
color: var(--muted);
font-size: 12px;
font-weight: 520;
line-height: 1.3;
white-space: nowrap;
}
.workspace-page-menu-list button em {
grid-row: span 2;
align-self: center;
padding: 4px 8px;
color: var(--muted);
background: #f6f6f7;
border-radius: 999px;
font-size: 11px;
font-style: normal;
font-weight: 760;
white-space: nowrap;
}
.workspace-current-page-actions .workspace-preview-mode-switch {
min-height: 38px;
background: #fff !important;
} }
.stage-3 .workspace-preview-panel, .stage-3 .workspace-preview-panel,
@ -6406,6 +6621,44 @@ input[type="checkbox"] {
box-shadow: none !important; box-shadow: none !important;
} }
.stage-3 .workspace-cockpit-panel {
position: relative;
gap: 10px !important;
padding: 12px !important;
overflow: hidden;
border-radius: 22px !important;
}
.workspace-preview-status {
display: flex;
flex: 1 1 360px;
flex-wrap: wrap;
gap: 7px;
padding: 0 2px 2px;
}
.workspace-preview-status span {
min-height: 26px;
display: inline-flex;
align-items: center;
padding: 0 9px;
color: var(--muted);
background: #f6f6f7;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
font-weight: 650;
}
.workspace-preview-shell {
position: relative;
min-height: clamp(620px, 68vh, 840px);
background: #fff;
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
}
.stage-3 .workspace-subheading { .stage-3 .workspace-subheading {
align-items: baseline !important; align-items: baseline !important;
padding: 0 2px; padding: 0 2px;
@ -6430,11 +6683,18 @@ input[type="checkbox"] {
border-radius: 22px !important; border-radius: 22px !important;
} }
.stage-3 .workspace-cockpit-panel .workspace-preview-frame {
position: absolute !important;
inset: 0;
border: 0 !important;
border-radius: 0 !important;
}
.stage-3 .workspace-preview-panel iframe { .stage-3 .workspace-preview-panel iframe {
width: max(1600px, 100%) !important; width: 100% !important;
max-width: none !important; max-width: none !important;
height: clamp(620px, 72vh, 860px) !important; height: 100% !important;
min-height: 620px !important; min-height: clamp(620px, 68vh, 840px) !important;
background: #fff !important; background: #fff !important;
border: 0 !important; border: 0 !important;
border-radius: 0 !important; border-radius: 0 !important;
@ -6459,7 +6719,238 @@ input[type="checkbox"] {
} }
.stage-3 .workspace-preview-frame.desktop-preview iframe { .stage-3 .workspace-preview-frame.desktop-preview iframe {
width: max(1600px, 100%) !important; width: 100% !important;
}
.workspace-drawer-stack {
position: absolute;
inset: 12px;
z-index: 5;
display: flex;
flex-direction: row-reverse;
align-items: stretch;
justify-content: flex-start;
gap: 12px;
pointer-events: none;
}
.workspace-drawer {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
flex: 0 0 calc((100% - 24px) / 3);
min-width: 0;
overflow: hidden;
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(24, 32, 29, 0.12);
border-radius: 18px;
box-shadow: 0 22px 68px rgba(24, 32, 29, 0.14);
pointer-events: auto;
animation: workspaceDrawerIn 180ms cubic-bezier(0.2, 0.82, 0.2, 1);
}
.workspace-drawer.open {
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
@keyframes workspaceDrawerIn {
from {
opacity: 0;
transform: translateX(18px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.workspace-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--line);
}
.workspace-drawer-header > div {
display: grid;
gap: 4px;
min-width: 0;
}
.workspace-drawer-header span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.workspace-drawer-header strong {
color: var(--ink);
font-size: 22px;
font-weight: 760;
letter-spacing: -0.03em;
line-height: 1;
}
.workspace-drawer-header small {
color: var(--muted);
font-size: 12px;
font-weight: 520;
line-height: 1.35;
}
.workspace-drawer-close {
width: 34px;
height: 34px;
display: grid;
flex: 0 0 auto;
place-items: center;
padding: 0;
color: var(--ink);
background: #f7f7f8;
border: 1px solid var(--line);
border-radius: 999px;
cursor: pointer;
}
.workspace-drawer-close:hover {
background: #efeff1;
}
.workspace-scope-list,
.workspace-fields-drawer,
.workspace-drawer > .workspace-issue-list {
min-height: 0;
overflow: auto;
padding: 12px;
}
.workspace-scope-list {
display: grid;
gap: 8px;
}
.workspace-scope-list button {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 4px 9px;
align-items: start;
width: 100%;
padding: 11px;
text-align: left;
background: #fff;
border: 1px solid var(--line);
border-radius: 14px;
cursor: pointer;
}
.workspace-scope-list button:hover,
.workspace-scope-list button.active {
background: #fffaf5;
border-color: rgba(242, 122, 26, 0.34);
}
.workspace-scope-list button span {
grid-row: span 2;
width: 26px;
height: 26px;
display: grid;
place-items: center;
color: var(--ink);
background: #f3f3f4;
border-radius: 999px;
font-size: 12px;
font-weight: 850;
}
.workspace-scope-list button.active span {
color: #fff;
background: var(--accent);
}
.workspace-scope-list strong,
.workspace-scope-list small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-scope-list strong {
color: var(--ink);
font-size: 13px;
font-weight: 760;
white-space: nowrap;
}
.workspace-scope-list small {
color: var(--muted);
font-size: 12px;
font-weight: 520;
white-space: nowrap;
}
.workspace-scope-list em {
grid-row: span 2;
align-self: center;
padding: 4px 8px;
color: var(--muted);
background: #f6f6f7;
border-radius: 999px;
font-size: 11px;
font-style: normal;
font-weight: 760;
white-space: nowrap;
}
.workspace-fields-drawer {
display: grid;
gap: 12px;
}
.workspace-fields-drawer .section-editor-card {
padding: 14px !important;
border-radius: 16px !important;
}
.workspace-fields-drawer .workspace-field-card.active {
background: #fffaf5 !important;
border-color: rgba(242, 122, 26, 0.36) !important;
}
.workspace-field-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.workspace-field-meta span,
.workspace-field-meta em {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
color: var(--muted);
background: #f5f5f6;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11px;
font-style: normal;
font-weight: 760;
line-height: 1;
}
.workspace-fields-drawer .section-editor-card pre {
max-height: 140px;
overflow: auto;
}
.workspace-fields-drawer .section-editor-card textarea {
min-height: 170px;
} }
.stage-3 .workspace-page-issues { .stage-3 .workspace-page-issues {
@ -6485,6 +6976,21 @@ input[type="checkbox"] {
transform: none !important; transform: none !important;
} }
.stage-3 .workspace-issue-list button {
grid-template-columns: auto minmax(0, 1fr) !important;
padding: 11px !important;
border-radius: 14px !important;
}
.stage-3 .workspace-issue-list button p {
grid-column: 2;
margin: 0;
color: var(--muted);
font-size: 12px;
font-weight: 520;
line-height: 1.35;
}
.stage-3 .section-map-card.active, .stage-3 .section-map-card.active,
.stage-3 .section-map-card.active:hover { .stage-3 .section-map-card.active:hover {
color: var(--ink) !important; color: var(--ink) !important;
@ -6524,9 +7030,33 @@ input[type="checkbox"] {
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.workspace-current-page { .workspace-cockpit-toolbar,
.workspace-current-page-actions {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
justify-content: flex-start;
}
.workspace-current-page-actions,
.workspace-current-page-actions > button,
.workspace-page-control,
.workspace-page-control-trigger,
.workspace-preview-mode-switch {
width: 100%;
}
.workspace-preview-shell {
min-height: 720px;
}
.workspace-drawer-stack {
inset: 8px;
flex-direction: column-reverse;
justify-content: flex-start;
}
.workspace-drawer {
flex: 0 0 min(76%, 620px);
} }
} }

View File

@ -337,7 +337,7 @@ function buildPreviewBridgeScript() {
return ` return `
<script> <script>
(() => { (() => {
const state = { workspaceKey: "", targets: [] }; const state = { workspaceKey: "", targets: [], scopePages: [], activeScopePageId: "", lastScopeReportAt: 0 };
const layerId = "seo-mode-preview-marker-layer"; const layerId = "seo-mode-preview-marker-layer";
let frame = 0; let frame = 0;
let lastActivation = { id: "", at: 0 }; let lastActivation = { id: "", at: 0 };
@ -525,6 +525,73 @@ function buildPreviewBridgeScript() {
return marker; return marker;
} }
function getElementForSelector(selector) {
if (!selector) return null;
try {
return document.querySelector(selector);
} catch {
return null;
}
}
function reportActiveScopePage() {
if (!state.workspaceKey || !Array.isArray(state.scopePages) || state.scopePages.length === 0) {
return;
}
let activeAtProbe = null;
let nearestBelow = null;
let lastAbove = null;
const probeLine = Math.min(Math.max(window.innerHeight * 0.38, 180), Math.max(180, window.innerHeight - 120));
for (const page of state.scopePages) {
const element = getElementForSelector(page.selector);
if (!element) continue;
const rect = element.getBoundingClientRect();
if (rect.height <= 0 || rect.width <= 0) continue;
if (rect.top <= probeLine && rect.bottom > probeLine) {
if (!activeAtProbe || rect.top > activeAtProbe.top) {
activeAtProbe = { pageId: page.pageId, top: rect.top };
}
continue;
}
if (rect.top > probeLine) {
if (!nearestBelow || rect.top < nearestBelow.top) {
nearestBelow = { pageId: page.pageId, top: rect.top };
}
continue;
}
if (!lastAbove || rect.bottom > lastAbove.bottom) {
lastAbove = { pageId: page.pageId, bottom: rect.bottom };
}
}
const bestPage = activeAtProbe || nearestBelow || lastAbove;
if (!bestPage || bestPage.pageId === state.activeScopePageId) {
return;
}
const now = Date.now();
if (now - state.lastScopeReportAt < 120) {
return;
}
state.activeScopePageId = bestPage.pageId;
state.lastScopeReportAt = now;
window.parent?.postMessage({
type: "seo-mode:active-scope-page",
workspaceKey: state.workspaceKey,
pageId: bestPage.pageId
}, "*");
}
function measure() { function measure() {
const layer = ensureLayer(); const layer = ensureLayer();
const root = document.documentElement; const root = document.documentElement;
@ -609,6 +676,8 @@ function buildPreviewBridgeScript() {
); );
}); });
} }
reportActiveScopePage();
} }
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
@ -620,6 +689,8 @@ function buildPreviewBridgeScript() {
state.workspaceKey = data.workspaceKey || ""; state.workspaceKey = data.workspaceKey || "";
state.targets = Array.isArray(data.targets) ? data.targets : []; state.targets = Array.isArray(data.targets) ? data.targets : [];
state.scopePages = Array.isArray(data.scopePages) ? data.scopePages : [];
state.activeScopePageId = data.activePageId || state.activeScopePageId || "";
scheduleMeasure(); scheduleMeasure();
}); });