feat(seo): refine keyword curation workspace

This commit is contained in:
DCCONSTRUCTIONS 2026-07-05 11:07:30 +03:00
parent a1d1a4980f
commit 2a0e72543e
5 changed files with 3870 additions and 419 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,246 @@
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
export type NdcGlassSelectOption<T extends string = string> = {
value: T;
label: string;
disabled?: boolean;
actions?: Array<{
ariaLabel: string;
disabled?: boolean;
icon: ReactNode;
onClick: () => void;
title?: string;
}>;
};
type NdcGlassSelectProps<T extends string = string> = {
value: T;
options: NdcGlassSelectOption<T>[];
ariaLabel?: string;
className?: string;
menuClassName?: string;
disabled?: boolean;
onChange: (value: T) => void;
};
export function NdcGlassSelect<T extends string = string>({
value,
options,
ariaLabel,
className,
menuClassName,
disabled = false,
onChange
}: NdcGlassSelectProps<T>) {
const [open, setOpen] = useState(false);
const [menuRect, setMenuRect] = useState({ top: 0, left: 0, width: 0 });
const rootRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const selectId = useMemo(() => `ndc-select-${Math.random().toString(16).slice(2)}`, []);
const selected = options.find((option) => option.value === value) ?? options[0];
const updateMenuRect = () => {
const root = rootRef.current;
if (!root) {
return;
}
const rect = root.getBoundingClientRect();
const valueRect = root.querySelector<HTMLElement>(".nodedc-select__value")?.getBoundingClientRect();
const menuMaxHeight = 232;
const bottomTop = rect.bottom + 6;
const top =
bottomTop + menuMaxHeight > window.innerHeight - 12
? Math.max(12, rect.top - menuMaxHeight - 6)
: bottomTop;
setMenuRect({
top,
left: valueRect?.left ?? rect.left,
width: Math.max(180, valueRect?.width ?? rect.width)
});
};
useEffect(() => {
if (!open) {
return;
}
updateMenuRect();
const handlePointerDown = (event: MouseEvent) => {
const root = rootRef.current;
const menu = menuRef.current;
if (
event.target instanceof Node &&
root &&
!root.contains(event.target) &&
(!menu || !menu.contains(event.target))
) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
const handleOtherSelectOpen = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string }>).detail;
if (detail?.id !== selectId) {
setOpen(false);
}
};
const handleViewportChange = () => updateMenuRect();
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [open, selectId]);
const openMenu = () => {
if (disabled) {
return;
}
setOpen((current) => {
if (!current) {
updateMenuRect();
window.dispatchEvent(new CustomEvent("nodedc-select-open", { detail: { id: selectId } }));
}
return !current;
});
};
return (
<div ref={rootRef} className={["nodedc-select", className].filter(Boolean).join(" ")}>
<div className="nodedc-select__control">
<div className="nodedc-select__value">{selected?.label}</div>
<button
aria-expanded={open}
aria-haspopup="listbox"
aria-label={ariaLabel}
className="nodedc-select__toggle"
disabled={disabled}
onClick={openMenu}
type="button"
>
<span aria-hidden="true" className="nodedc-select__chevron" />
</button>
</div>
{open
? createPortal(
<div
ref={menuRef}
className={["nodedc-dropdown-surface nodedc-select__menu", menuClassName].filter(Boolean).join(" ")}
role="listbox"
style={{
left: menuRect.left,
position: "fixed",
top: menuRect.top,
width: menuRect.width
}}
>
{options.map((option) => (
<div
aria-disabled={option.disabled === true}
aria-selected={option.value === value}
className="nodedc-dropdown-option nodedc-select__option"
data-disabled={option.disabled === true ? "true" : undefined}
data-selected={option.value === value ? "true" : undefined}
data-value={option.value}
key={option.value}
onClick={() => {
if (option.disabled) {
return;
}
onChange(option.value);
setOpen(false);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
if (option.disabled) {
return;
}
onChange(option.value);
setOpen(false);
}}
role="option"
tabIndex={option.disabled ? -1 : 0}
>
<span className="nodedc-select__option-label">{option.label}</span>
{option.actions?.length ? (
<span className="nodedc-select__option-actions" aria-hidden={false}>
{option.actions.map((action) => (
<span
aria-label={action.ariaLabel}
className="nodedc-select__option-action"
data-disabled={action.disabled ? "true" : undefined}
key={action.ariaLabel}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (!action.disabled) {
action.onClick();
setOpen(false);
}
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
event.stopPropagation();
if (!action.disabled) {
action.onClick();
setOpen(false);
}
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
role="button"
tabIndex={action.disabled ? -1 : 0}
title={action.title ?? action.ariaLabel}
>
{action.icon}
</span>
))}
</span>
) : null}
</div>
))}
</div>,
document.body
)
: null}
</div>
);
}

View File

@ -336,6 +336,46 @@ export type SemanticAnalysisRun = {
wordCount: number; wordCount: number;
matchedClusters: string[]; matchedClusters: string[];
}>; }>;
textCorpus: {
schemaVersion: "seo-text-corpus.v1";
extraction: {
chunkCount: number;
documentCount: number;
mode: "clean_visible_text";
sourceDocumentCount: number;
totalChars: number;
totalWords: number;
};
documents: Array<{
chunkIds: string[];
id: string;
kind: "page" | "content_json";
scope: "indexable_page" | "template_block" | "admin_page" | "technical_page" | "content_source";
selected: boolean;
sourcePath: string;
textCharCount: number;
title: string;
urlPath: string | null;
usedForCoverage: boolean;
wordCount: number;
}>;
chunks: Array<{
charEnd: number;
charStart: number;
chunkIndex: number;
documentId: string;
id: string;
kind: "page" | "content_json";
scope: "indexable_page" | "template_block" | "admin_page" | "technical_page" | "content_source";
selected: boolean;
sourcePath: string;
text: string;
title: string;
totalChunks: number;
urlPath: string | null;
usedForCoverage: boolean;
}>;
};
legacyFindings: Array<{ legacyFindings: Array<{
markerId: string; markerId: string;
label: string; label: string;
@ -556,7 +596,7 @@ export type SeoContextReviewRun = {
runId: string; runId: string;
status: string; status: string;
provider: { provider: {
mode: "codex_manual" | "deterministic_fallback"; mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
modelRequired: true; modelRequired: true;
message: string; message: string;
}; };
@ -616,7 +656,7 @@ export type SeoNormalizationContract = {
generatedAt: string; generatedAt: string;
state: "not_ready" | "ready"; state: "not_ready" | "ready";
provider: { provider: {
mode: "codex_manual" | "deterministic_fallback"; mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
modelRequired: boolean; modelRequired: boolean;
message: string; message: string;
}; };
@ -737,9 +777,11 @@ export type AnchorReviewDecision = {
sendToSerp: boolean; sendToSerp: boolean;
sendToWordstat: boolean; sendToWordstat: boolean;
source: "human" | "model" | "system"; source: "human" | "model" | "system";
status: "approved" | "disabled" | "pending"; status: "approved" | "deleted" | "disabled" | "pending";
}; };
export type DemandCollectionProfile = "contextual" | "market_wide";
export type AnchorReviewContract = { export type AnchorReviewContract = {
schemaVersion: "anchor-review.v1"; schemaVersion: "anchor-review.v1";
projectId: string; projectId: string;
@ -747,6 +789,7 @@ export type AnchorReviewContract = {
projectOntologyVersionId: string | null; projectOntologyVersionId: string | null;
generatedAt: string; generatedAt: string;
state: "approved" | "empty" | "needs_review" | "not_ready"; state: "approved" | "empty" | "needs_review" | "not_ready";
demandCollectionProfile: DemandCollectionProfile;
source: { source: {
normalizationGeneratedAt: string | null; normalizationGeneratedAt: string | null;
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null; normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
@ -794,6 +837,13 @@ export type AnchorReviewContract = {
}; };
decision: AnchorReviewDecision; decision: AnchorReviewDecision;
}>; }>;
deletedAnchors: Array<{
deletedAt: string | null;
id: string;
key: string;
phrase: string;
reason: string;
}>;
wordstatQueue: Array<{ wordstatQueue: Array<{
id: string; id: string;
anchorId: string; anchorId: string;
@ -845,6 +895,7 @@ export type MarketEnrichmentContract = {
state: "not_ready" | "not_collected" | "not_configured" | "partial_ready"; state: "not_ready" | "not_collected" | "not_configured" | "partial_ready";
normalization: SeoNormalizationContract; normalization: SeoNormalizationContract;
anchorReview: AnchorReviewContract; anchorReview: AnchorReviewContract;
demandCollectionProfile: DemandCollectionProfile;
readiness: { readiness: {
anchorApprovedCount: number; anchorApprovedCount: number;
anchorPendingCount: number; anchorPendingCount: number;
@ -995,6 +1046,115 @@ export type MarketEnrichmentContract = {
nextActions: string[]; nextActions: string[];
}; };
export type WordstatProbePlanSource =
| "approved_anchor"
| "commercial_demand"
| "market_anchor_expansion"
| "market_frontier_discovery"
| "previous_expansion";
export type WordstatProbePlanRejectedStatus =
| "already_collected"
| "already_requested"
| "duplicate_candidate"
| "invalid_phrase"
| "not_selected_budget_or_balance"
| "suppressed";
export type WordstatProbePlanSelectedProbe = {
phrase: string;
normalizedPhrase: string;
clusterId: string;
clusterTitle: string;
priority: "high" | "medium" | "low";
source: WordstatProbePlanSource;
score: number;
reason: string;
};
export type WordstatProbePlanWarning = {
code:
| "frontier_missing"
| "high_reuse_blocking"
| "low_diversity"
| "low_selected_count"
| "weak_generic_probes";
level: "info" | "warning";
message: string;
examples: string[];
};
export type WordstatProbePlanPreviewContract = {
schemaVersion: "wordstat-probe-plan-preview.v1";
projectId: string;
semanticRunId: string | null;
generatedAt: string;
demandCollectionProfile: DemandCollectionProfile;
orchestration: {
mode: "backend_orchestrated_contract_pipeline";
executionMode: "preview_only";
modelCanCallWordstat: false;
steps: string[];
};
provider: {
wordstatStatus: MarketEnrichmentContract["providers"][number]["status"];
wordstatMode: string;
};
readiness: {
canExecute: boolean;
blockers: string[];
probeBudget: number;
candidateCount: number;
selectedCount: number;
};
memory: {
requestedPhraseCount: number;
collectedPhraseCount: number;
suppressedPhraseCount: number;
frontierSourceTaskId: string | null;
frontierProbeSeedCount: number;
};
selectedProbes: WordstatProbePlanSelectedProbe[];
warnings: WordstatProbePlanWarning[];
candidateSourceCounts: Record<WordstatProbePlanSource, number>;
rejectedSummary: Record<WordstatProbePlanRejectedStatus, number>;
rejectedSamples: Array<WordstatProbePlanSelectedProbe & {
status: WordstatProbePlanRejectedStatus | "selected";
statusLabel: string;
}>;
nextActions: string[];
};
export type KeywordAnalysisWorkflowContract = {
schemaVersion: "keyword-analysis-workflow.v1";
runId: string;
projectId: string;
semanticRunId: string | null;
generatedAt: string;
status: "blocked" | "completed" | "failed" | "queued" | "running" | "waiting";
statusLabel: string;
activeStepId: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis" | null;
activeStepLabel: string | null;
summary: string;
reason: string | null;
steps: Array<{
id: "demand_collection" | "keyword_cleaning" | "keyword_proposal" | "project_context" | "semantic_basis";
label: string;
status: "blocked" | "completed" | "failed" | "pending" | "running" | "waiting";
detail: string;
reason: string | null;
startedAt: string | null;
completedAt: string | null;
}>;
artifacts: {
keywordCleaningRunId: string | null;
marketGeneratedAt: string | null;
modelTaskRunId: string | null;
wordstatJobId: string | null;
};
nextActions: string[];
};
export type KeywordCleaningItem = { export type KeywordCleaningItem = {
id: string; id: string;
phrase: string; phrase: string;
@ -1029,7 +1189,7 @@ export type KeywordCleaningContract = {
generatedAt: string; generatedAt: string;
state: "not_ready" | "ready"; state: "not_ready" | "ready";
provider: { provider: {
mode: "codex_manual" | "deterministic_fallback"; mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
modelRequired: boolean; modelRequired: boolean;
message: string; message: string;
}; };
@ -1093,8 +1253,11 @@ export type KeywordCleaningContract = {
}; };
export type SeoModelTaskType = export type SeoModelTaskType =
| "seo.business_synthesis"
| "seo.commercial_demand"
| "seo.context_review" | "seo.context_review"
| "seo.keyword_cleaning" | "seo.keyword_cleaning"
| "seo.market_frontier_discovery"
| "seo.normalization" | "seo.normalization"
| "seo.serp_interpretation" | "seo.serp_interpretation"
| "seo.strategy_quality_review" | "seo.strategy_quality_review"
@ -1167,7 +1330,10 @@ export type SeoModelTaskPersistedResult = {
schemaVersion: "seo-model-persisted-result.v1"; schemaVersion: "seo-model-persisted-result.v1";
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence"; status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
runType: runType:
| "seo_business_synthesis"
| "seo_commercial_demand"
| "keyword_cleaning" | "keyword_cleaning"
| "seo_context_review"
| "seo_model_task" | "seo_model_task"
| "seo_normalization" | "seo_normalization"
| "seo_strategy_quality_review" | "seo_strategy_quality_review"
@ -1322,7 +1488,7 @@ export type SerpInterpretationContract = {
generatedAt: string; generatedAt: string;
state: "not_ready" | "ready"; state: "not_ready" | "ready";
provider: { provider: {
mode: "codex_manual" | "deterministic_fallback"; mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
modelRequired: true; modelRequired: true;
message: string; message: string;
}; };
@ -1466,6 +1632,31 @@ export type KeywordMapRoleOverrideInput = {
role: KeywordMapRole; role: KeywordMapRole;
}; };
export type KeywordContextSnapshotSummary = {
id: string;
label: string;
source: "expansion_checkpoint" | "manual_save";
createdAt: string;
semanticRunId: string | null;
keywordMapGeneratedAt: string | null;
keywordCount: number;
keywordPhrases: string[];
suppressedCount: number;
roleOverrideCount: number;
};
export type KeywordContextSnapshotInput = {
label?: string;
note?: string;
source?: "expansion_checkpoint" | "manual_save";
curation?: {
roleOverrides?: KeywordMapRoleOverrideInput[];
suppressedItemIds?: string[];
};
};
export type KeywordContextSnapshotCurationInput = NonNullable<KeywordContextSnapshotInput["curation"]>;
export type SeoStrategyContract = { export type SeoStrategyContract = {
schemaVersion: "seo-strategy.v1"; schemaVersion: "seo-strategy.v1";
projectId: string; projectId: string;
@ -1650,6 +1841,16 @@ export type SeoStrategyContract = {
nextActions: string[]; nextActions: string[];
}; };
export type StrategyPhaseAction = {
role?: string;
title: string;
action?: string;
reason?: string;
priority?: "high" | "low" | "medium";
targetPath?: string | null;
evidenceRefs?: string[];
};
export type StrategySynthesisContract = { export type StrategySynthesisContract = {
schemaVersion: "seo-strategy-synthesis.v1"; schemaVersion: "seo-strategy-synthesis.v1";
projectId: string; projectId: string;
@ -1658,7 +1859,7 @@ export type StrategySynthesisContract = {
generatedAt: string; generatedAt: string;
state: "not_ready" | "ready"; state: "not_ready" | "ready";
provider: { provider: {
mode: "codex_manual" | "deterministic_fallback"; mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
modelRequired: true; modelRequired: true;
message: string; message: string;
}; };
@ -1697,7 +1898,13 @@ export type StrategySynthesisContract = {
promotionSignals: string[]; promotionSignals: string[];
phaseRoadmap: SeoStrategyContract["phasePlan"]; phaseRoadmap: SeoStrategyContract["phasePlan"];
}; };
phaseStrategies: SeoStrategyContract["phaseStrategies"]; phaseStrategies: Array<
Omit<SeoStrategyContract["phaseStrategies"][number], "doNow" | "hold" | "prepareNext"> & {
doNow: Array<string | StrategyPhaseAction>;
hold: Array<string | StrategyPhaseAction>;
prepareNext: Array<string | StrategyPhaseAction>;
}
>;
recommendedPath: { recommendedPath: {
id: string; id: string;
title: string; title: string;
@ -1759,7 +1966,7 @@ export type StrategyQualityReviewContract = {
generatedAt: string; generatedAt: string;
state: "not_ready" | "ready"; state: "not_ready" | "ready";
provider: { provider: {
mode: "codex_manual" | "deterministic_fallback"; mode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
modelRequired: true; modelRequired: true;
message: string; message: string;
}; };
@ -2874,6 +3081,16 @@ export async function fetchLatestMarketEnrichment(projectId: string) {
return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>; return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>;
} }
export async function fetchWordstatProbePlanPreview(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/market-enrichment/probe-preview`);
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось построить Wordstat probe preview: ${response.status}`));
}
return response.json() as Promise<{ probePreview: WordstatProbePlanPreviewContract }>;
}
export async function fetchLatestSeoContextReview(projectId: string) { export async function fetchLatestSeoContextReview(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/context-review/latest`); const response = await fetch(`${apiBaseUrl}/projects/${projectId}/context-review/latest`);
@ -2981,9 +3198,26 @@ export async function fetchLatestAnchorReview(projectId: string) {
return response.json() as Promise<{ anchorReview: AnchorReviewContract }>; return response.json() as Promise<{ anchorReview: AnchorReviewContract }>;
} }
export async function saveAnchorReviewDecisions(projectId: string, decisions?: AnchorReviewDecisionInput[]) { export async function saveAnchorReviewDecisions(
projectId: string,
decisions?: AnchorReviewDecisionInput[],
demandCollectionProfile?: DemandCollectionProfile
) {
const payload: {
decisions?: AnchorReviewDecisionInput[];
demandCollectionProfile?: DemandCollectionProfile;
} = {};
if (decisions) {
payload.decisions = decisions;
}
if (demandCollectionProfile) {
payload.demandCollectionProfile = demandCollectionProfile;
}
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/decisions`, { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/decisions`, {
body: JSON.stringify({ decisions }), body: JSON.stringify(payload),
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
@ -3051,6 +3285,28 @@ export async function runMarketEnrichment(projectId: string) {
return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>; return response.json() as Promise<{ marketEnrichment: MarketEnrichmentContract }>;
} }
export async function fetchLatestKeywordAnalysisWorkflow(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-analysis-workflow/latest`);
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось загрузить workflow анализа ключей: ${response.status}`));
}
return response.json() as Promise<{ workflow: KeywordAnalysisWorkflowContract | null }>;
}
export async function startKeywordAnalysisWorkflow(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-analysis-workflow`, {
method: "POST"
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось запустить workflow анализа ключей: ${response.status}`));
}
return response.json() as Promise<{ workflow: KeywordAnalysisWorkflowContract }>;
}
export async function fetchLatestYandexEvidence(projectId: string) { export async function fetchLatestYandexEvidence(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/yandex-evidence/latest`); const response = await fetch(`${apiBaseUrl}/projects/${projectId}/yandex-evidence/latest`);
@ -3168,6 +3424,7 @@ export async function approveKeywordMapDecisions(
input?: { input?: {
itemIds?: string[]; itemIds?: string[];
roleOverrides?: KeywordMapRoleOverrideInput[]; roleOverrides?: KeywordMapRoleOverrideInput[];
suppressedItemIds?: string[];
} }
) { ) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-map/decisions`, { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-map/decisions`, {
@ -3190,6 +3447,78 @@ export async function approveKeywordMapDecisions(
}>; }>;
} }
export async function fetchKeywordContextSnapshots(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`);
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось загрузить snapshot-профили ключей: ${response.status}`));
}
return response.json() as Promise<{ snapshots: KeywordContextSnapshotSummary[] }>;
}
export async function saveKeywordContextSnapshot(projectId: string, input: KeywordContextSnapshotInput) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(input)
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось сохранить snapshot ключей: ${response.status}`));
}
return response.json() as Promise<{ snapshot: KeywordContextSnapshotSummary }>;
}
export async function renameKeywordContextSnapshot(projectId: string, snapshotId: string, label: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ label })
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось переименовать snapshot ключей: ${response.status}`));
}
return response.json() as Promise<{ snapshot: KeywordContextSnapshotSummary }>;
}
export async function updateKeywordContextSnapshotCuration(
projectId: string,
snapshotId: string,
curation: KeywordContextSnapshotCurationInput
) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}/curation`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(curation)
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось сохранить раскладку ключей: ${response.status}`));
}
return response.json() as Promise<{ snapshot: KeywordContextSnapshotSummary }>;
}
export async function deleteKeywordContextSnapshot(projectId: string, snapshotId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}`, {
method: "DELETE"
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось удалить snapshot ключей: ${response.status}`));
}
}
export async function fetchLatestRewritePlan(projectId: string) { export async function fetchLatestRewritePlan(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/rewrite-plan/latest`); const response = await fetch(`${apiBaseUrl}/projects/${projectId}/rewrite-plan/latest`);

View File

@ -1,9 +1,49 @@
import { StrictMode } from "react"; import { Component, StrictMode, type ErrorInfo, type ReactNode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
type AppErrorBoundaryProps = {
children: ReactNode;
};
type AppErrorBoundaryState = {
error: Error | null;
};
class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
state: AppErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): AppErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("NDC SEO UI render error", error, errorInfo);
}
render() {
if (this.state.error) {
return (
<main className="app-fatal-state">
<section>
<strong>Интерфейс SEO Mode упал при отрисовке</strong>
<p>{this.state.error.message}</p>
<button onClick={() => window.location.reload()} type="button">
Перезагрузить интерфейс
</button>
</section>
</main>
);
}
return this.props.children;
}
}
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<AppErrorBoundary>
<App /> <App />
</AppErrorBoundary>
</StrictMode> </StrictMode>
); );

View File

@ -2748,6 +2748,11 @@ input {
line-height: 1.38; line-height: 1.38;
} }
.stage-6 .market-heading .strategy-stage-run-date {
color: var(--ink);
font-weight: 850;
}
.stage-6 .market-heading > svg { .stage-6 .market-heading > svg {
flex: 0 0 auto; flex: 0 0 auto;
width: 22px; width: 22px;
@ -2760,6 +2765,51 @@ input {
box-sizing: content-box; box-sizing: content-box;
} }
.app-fatal-state {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
color: var(--ink);
background: linear-gradient(135deg, #f2f2f2, #ffffff);
}
.app-fatal-state section {
display: grid;
gap: 12px;
width: min(520px, 100%);
padding: 22px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow-soft);
}
.app-fatal-state strong {
font-size: 18px;
}
.app-fatal-state p {
margin: 0;
color: var(--muted);
font-size: 13px;
font-weight: 740;
line-height: 1.45;
overflow-wrap: anywhere;
}
.app-fatal-state button {
justify-self: start;
min-height: 38px;
padding: 0 14px;
color: #fff;
background: var(--ink);
border: 0;
border-radius: 999px;
font-weight: 900;
cursor: pointer;
}
.market-state-card { .market-state-card {
align-items: center; align-items: center;
padding: 10px; padding: 10px;
@ -2882,6 +2932,18 @@ input {
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055); box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
} }
.keyword-stage-section.collapsed {
gap: 0;
}
.keyword-stage-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.keyword-stage-heading { .keyword-stage-heading {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -2949,6 +3011,20 @@ input {
color: #fff; color: #fff;
} }
.keyword-stage-icon-action.active {
color: #fff;
background: #e83e8c;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 14px 34px rgba(232, 62, 140, 0.24);
}
.keyword-stage-toggle svg {
transition: transform 140ms ease;
}
.keyword-stage-toggle.expanded svg {
transform: rotate(180deg);
}
.keyword-stage-icon-action:disabled { .keyword-stage-icon-action:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.52; opacity: 0.52;
@ -2970,6 +3046,145 @@ input {
flex: 1 1 auto; flex: 1 1 auto;
} }
.keyword-stage-head > .keyword-stage-heading {
flex: 1 1 auto;
}
.keyword-stage-process-card {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
padding: 14px;
background: rgba(24, 32, 29, 0.035);
border: 1px solid rgba(24, 32, 29, 0.07);
border-radius: 12px;
}
.keyword-stage-process-card.active {
background: rgba(232, 62, 140, 0.045);
border-color: rgba(232, 62, 140, 0.16);
}
.keyword-stage-process-card.blocked {
background: rgba(139, 47, 47, 0.035);
border-color: rgba(139, 47, 47, 0.12);
}
.keyword-stage-process-card > svg {
flex: 0 0 auto;
color: #e83e8c;
}
.keyword-stage-process-card.blocked > svg {
color: #8b2f2f;
}
.keyword-stage-process-card > div {
display: grid;
gap: 4px;
min-width: 0;
}
.keyword-stage-process-card span {
justify-self: start;
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 9px;
color: #2e5a67;
background: rgba(46, 90, 103, 0.08);
border: 1px solid rgba(46, 90, 103, 0.14);
border-radius: 999px;
font-size: 11px;
font-weight: 900;
}
.keyword-stage-process-card strong {
min-width: 0;
color: var(--ink);
font-size: 13px;
font-weight: 900;
overflow-wrap: anywhere;
}
.keyword-stage-process-card small {
min-width: 0;
color: var(--muted);
font-size: 12px;
font-weight: 740;
line-height: 1.38;
overflow-wrap: anywhere;
}
.keyword-workflow-step-list {
display: grid;
gap: 7px;
margin-top: 10px;
}
.keyword-workflow-step-list > div {
display: grid;
grid-template-columns: minmax(160px, 0.5fr) auto minmax(220px, 1fr);
align-items: center;
gap: 10px;
min-width: 0;
padding: 8px 10px;
border: 1px solid rgba(24, 32, 29, 0.07);
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
}
.keyword-workflow-step-list span,
.keyword-workflow-step-list strong,
.keyword-workflow-step-list small {
min-width: 0;
overflow-wrap: anywhere;
}
.keyword-workflow-step-list span {
color: var(--ink);
font-size: 12px;
font-weight: 900;
}
.keyword-workflow-step-list strong {
justify-self: start;
min-height: 20px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: rgba(46, 90, 103, 0.08);
color: #2e5a67;
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
}
.keyword-workflow-step-list small {
color: var(--muted);
font-size: 11px;
font-weight: 740;
}
.keyword-workflow-step-list .completed strong {
background: rgba(47, 122, 89, 0.1);
color: #2f7a59;
}
.keyword-workflow-step-list .blocked strong,
.keyword-workflow-step-list .failed strong {
background: rgba(139, 47, 47, 0.08);
color: #8b2f2f;
}
.keyword-workflow-step-list .running strong,
.keyword-workflow-step-list .waiting strong {
background: rgba(232, 62, 140, 0.1);
color: #bc2f72;
}
.anchor-review-row > div:first-child span { .anchor-review-row > div:first-child span {
justify-self: start; justify-self: start;
min-height: 22px; min-height: 22px;
@ -3029,11 +3244,11 @@ input {
flex: 1 1 auto; flex: 1 1 auto;
} }
.keyword-curation-card > span { .keyword-curation-card-meta > span:last-child {
justify-self: start;
min-height: 22px; min-height: 22px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-width: 0;
padding: 0 9px; padding: 0 9px;
color: rgba(8, 8, 10, 0.78); color: rgba(8, 8, 10, 0.78);
background: rgba(8, 8, 10, 0.07); background: rgba(8, 8, 10, 0.07);
@ -3041,6 +3256,7 @@ input {
border-radius: 999px; border-radius: 999px;
font-size: 11px; font-size: 11px;
font-weight: 900; font-weight: 900;
overflow-wrap: anywhere;
} }
.keyword-curation-column-head strong, .keyword-curation-column-head strong,
@ -3146,10 +3362,11 @@ input {
} }
.keyword-curation-card { .keyword-curation-card {
position: relative;
display: grid; display: grid;
gap: 5px; gap: 5px;
min-width: 0; min-width: 0;
padding: 10px; padding: 10px 42px 10px 10px;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(24, 32, 29, 0.08); border: 1px solid rgba(24, 32, 29, 0.08);
border-radius: var(--keyword-curation-radius); border-radius: var(--keyword-curation-radius);
@ -3157,6 +3374,40 @@ input {
user-select: none; user-select: none;
} }
.keyword-curation-trash {
position: absolute;
top: 10px;
right: 10px;
display: grid;
width: 28px;
height: 28px;
place-items: center;
padding: 0;
color: #d52978;
background: rgba(255, 255, 255, 0.92);
border: 1.5px solid rgba(213, 41, 120, 0.72);
border-radius: 999px;
box-shadow: 0 8px 20px rgba(232, 62, 140, 0.08);
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.keyword-curation-trash:hover {
color: #ffffff;
background: #e83e8c;
border-color: #e83e8c;
transform: translateY(-1px);
}
.keyword-curation-trash:focus-visible {
outline: 2px solid rgba(232, 62, 140, 0.32);
outline-offset: 2px;
}
.keyword-curation-drag-preview { .keyword-curation-drag-preview {
position: fixed; position: fixed;
left: var(--keyword-drag-x); left: var(--keyword-drag-x);
@ -3173,6 +3424,29 @@ input {
box-shadow: 0 24px 54px rgba(24, 32, 29, 0.22); box-shadow: 0 24px 54px rgba(24, 32, 29, 0.22);
} }
.keyword-curation-card-meta {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.keyword-curation-freshness {
display: inline-block;
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 999px;
}
.keyword-curation-freshness.old {
background: #d7a11a;
}
.keyword-curation-freshness.new {
background: #18a957;
}
.keyword-curation-empty { .keyword-curation-empty {
min-height: 88px; min-height: 88px;
display: grid; display: grid;
@ -3187,6 +3461,115 @@ input {
text-align: center; text-align: center;
} }
.wordstat-probe-preview {
display: grid;
gap: 8px;
min-width: 0;
padding: 10px;
background: rgba(24, 32, 29, 0.035);
border: 1px solid rgba(24, 32, 29, 0.08);
border-radius: var(--keyword-curation-radius);
}
.wordstat-probe-preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.wordstat-probe-preview-head > div:first-child {
display: grid;
gap: 3px;
min-width: 0;
}
.wordstat-probe-preview-head span,
.wordstat-probe-preview-head small,
.wordstat-probe-preview-empty,
.wordstat-probe-preview-hash {
min-width: 0;
color: var(--muted);
font-size: 11px;
font-weight: 800;
line-height: 1.35;
overflow-wrap: anywhere;
}
.wordstat-probe-preview-head strong {
min-width: 0;
color: var(--ink);
font-size: 14px;
font-weight: 920;
overflow-wrap: anywhere;
}
.wordstat-probe-preview-head .ready,
.wordstat-probe-preview-head .blocked {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 900;
}
.wordstat-probe-preview-head .ready {
color: #0a6a42;
background: rgba(20, 148, 92, 0.1);
}
.wordstat-probe-preview-head .blocked {
color: #9c2f2f;
background: rgba(210, 66, 66, 0.1);
}
.wordstat-probe-preview-stats,
.wordstat-probe-preview-warnings,
.wordstat-probe-preview-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.wordstat-probe-preview-stats span,
.wordstat-probe-preview-warnings span,
.wordstat-probe-preview-list span {
display: inline-flex;
align-items: center;
min-height: 24px;
max-width: 100%;
padding: 0 8px;
color: rgba(8, 8, 10, 0.78);
background: rgba(255, 255, 255, 0.74);
border: 1px solid rgba(24, 32, 29, 0.08);
border-radius: 999px;
font-size: 11px;
font-weight: 840;
overflow-wrap: anywhere;
}
.wordstat-probe-preview-warnings span {
color: #735018;
background: rgba(255, 202, 96, 0.14);
border-color: rgba(170, 115, 20, 0.16);
}
.wordstat-probe-preview-warnings span.warning {
color: #8d2f24;
background: rgba(220, 74, 56, 0.1);
border-color: rgba(184, 58, 43, 0.16);
}
.wordstat-probe-preview-list span {
color: var(--ink);
background: rgba(255, 255, 255, 0.9);
}
.keyword-curation-footer { .keyword-curation-footer {
align-items: center; align-items: center;
padding-top: 2px; padding-top: 2px;
@ -3236,6 +3619,228 @@ input {
font-weight: 900; font-weight: 900;
} }
.anchor-demand-profile-card {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 380px);
gap: 16px;
align-items: center;
min-width: 0;
padding: 12px;
background: rgba(24, 32, 29, 0.034);
border: 1px solid rgba(24, 32, 29, 0.07);
border-radius: 10px;
}
.anchor-demand-profile-card > div:first-child {
display: grid;
gap: 4px;
min-width: 0;
}
.anchor-demand-profile-card span {
color: #2e5a67;
font-size: 11px;
font-weight: 900;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.anchor-demand-profile-card strong {
color: var(--ink);
font-size: 14px;
font-weight: 900;
line-height: 1.2;
}
.anchor-demand-profile-card small {
color: var(--muted);
font-size: 12px;
font-weight: 740;
line-height: 1.35;
}
.anchor-demand-profile-control {
display: grid;
gap: 7px;
justify-items: stretch;
min-width: 0;
}
.anchor-demand-profile-control > span {
justify-self: end;
padding: 4px 9px;
color: #7a1f4d;
background: rgba(232, 62, 140, 0.1);
border: 1px solid rgba(232, 62, 140, 0.18);
border-radius: 999px;
letter-spacing: 0;
text-transform: none;
}
.nodedc-select {
position: relative;
min-width: 0;
width: 100%;
}
.nodedc-select__control {
display: grid;
grid-template-columns: minmax(0, 1fr) 40px;
gap: 8px;
align-items: center;
min-width: 0;
}
.nodedc-select__value,
.nodedc-select__toggle {
height: 40px;
color: rgba(20, 23, 25, 0.86);
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(24, 32, 29, 0.08);
border-radius: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92), 0 12px 28px rgba(18, 22, 25, 0.08);
outline: none;
transition: background 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.nodedc-select__value {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
padding: 0 12px;
font-size: 12px;
font-weight: 820;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodedc-select__toggle {
display: grid;
place-items: center;
padding: 0;
cursor: pointer;
}
.nodedc-select__value:hover,
.nodedc-select__toggle:hover,
.nodedc-select__toggle[aria-expanded="true"] {
color: rgba(10, 12, 14, 0.94);
background: rgba(255, 255, 255, 0.96);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96), 0 14px 34px rgba(18, 22, 25, 0.1);
}
.nodedc-select__toggle:focus,
.nodedc-select__toggle:focus-visible,
.nodedc-select__value:focus,
.nodedc-select__value:focus-visible,
.nodedc-select__option:focus,
.nodedc-select__option:focus-visible {
outline: none;
box-shadow: none;
}
.nodedc-select__chevron {
width: 8px;
height: 8px;
opacity: 0.72;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: translateY(-2px) rotate(45deg);
}
.nodedc-dropdown-surface.nodedc-select__menu {
z-index: 30000;
display: grid;
gap: 2px;
max-height: 232px;
padding: 7px;
overflow: auto;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(24, 32, 29, 0.08);
border-radius: 20px;
box-shadow: 0 18px 60px rgba(24, 32, 29, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.8);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
scrollbar-width: none;
}
.nodedc-dropdown-surface.nodedc-select__menu::-webkit-scrollbar {
display: none;
}
.nodedc-dropdown-option.nodedc-select__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 42px;
padding: 0 12px;
color: rgba(28, 31, 33, 0.74);
font: inherit;
font-size: 12px;
font-weight: 820;
text-align: left;
background: transparent;
border: 0;
border-radius: 14px;
cursor: pointer;
}
.nodedc-select__option-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodedc-select__option-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 5px;
}
.nodedc-select__option-action {
display: grid;
width: 26px;
height: 26px;
place-items: center;
color: rgba(10, 12, 14, 0.68);
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(24, 32, 29, 0.08);
border-radius: 999px;
cursor: pointer;
transition: background 140ms ease, color 140ms ease, transform 140ms ease;
}
.nodedc-select__option-action:hover,
.nodedc-select__option-action:focus-visible {
color: #e83e8c;
background: rgba(232, 62, 140, 0.1);
transform: translateY(-1px);
outline: none;
}
.nodedc-select__option-action[data-disabled="true"] {
cursor: not-allowed;
opacity: 0.46;
transform: none;
}
.nodedc-dropdown-option.nodedc-select__option:hover,
.nodedc-dropdown-option.nodedc-select__option:focus,
.nodedc-dropdown-option.nodedc-select__option[data-selected="true"] {
color: rgba(10, 12, 14, 0.95);
background: rgba(24, 32, 29, 0.065);
}
.nodedc-dropdown-option.nodedc-select__option[data-disabled="true"] {
cursor: not-allowed;
opacity: 0.54;
}
.anchor-review-workspace-list { .anchor-review-workspace-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
@ -12258,6 +12863,8 @@ textarea:focus {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.keyword-stage-head,
.keyword-anchor-stage-head,
.keyword-curation-board-head, .keyword-curation-board-head,
.keyword-curation-footer { .keyword-curation-footer {
flex-direction: column; flex-direction: column;
@ -12271,6 +12878,10 @@ textarea:focus {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.anchor-demand-profile-card {
grid-template-columns: 1fr;
}
.anchor-review-row { .anchor-review-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
align-items: start; align-items: start;
@ -13075,9 +13686,9 @@ textarea:focus {
display: grid; display: grid;
place-items: center; place-items: center;
padding: 1rem; padding: 1rem;
background: rgba(10, 12, 14, 0.28); background: rgba(10, 12, 14, 0.14);
backdrop-filter: var(--nodedc-glass-panel-blur); backdrop-filter: blur(18px) saturate(1.04);
-webkit-backdrop-filter: var(--nodedc-glass-panel-blur); -webkit-backdrop-filter: blur(18px) saturate(1.04);
} }
.seo-project-name-modal { .seo-project-name-modal {
@ -13148,6 +13759,62 @@ textarea:focus {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.seo-keyword-suppress-modal {
width: min(36rem, 100%);
}
.seo-keyword-context-modal {
width: min(38rem, 100%);
}
.seo-keyword-suppress-preview {
display: grid;
gap: 0.35rem;
min-width: 0;
padding: 0.88rem;
background: rgba(255, 255, 255, 0.74);
border: 1px solid rgba(24, 32, 29, 0.07);
border-radius: 1rem;
box-shadow: 0 12px 32px rgba(24, 32, 29, 0.06);
}
.seo-keyword-suppress-preview span {
justify-self: start;
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 9px;
color: rgba(8, 8, 10, 0.78);
background: rgba(8, 8, 10, 0.07);
border-radius: 999px;
font-size: 11px;
font-weight: 900;
}
.seo-keyword-suppress-preview strong {
color: var(--ink);
font-size: 1rem;
font-weight: 900;
line-height: 1.16;
overflow-wrap: anywhere;
}
.seo-keyword-suppress-preview small,
.seo-keyword-suppress-note {
margin: 0;
color: var(--muted);
font-size: 0.78rem;
font-weight: 740;
line-height: 1.42;
overflow-wrap: anywhere;
}
.seo-project-name-modal-actions .danger-action {
background: #e83e8c !important;
border-color: #e83e8c !important;
box-shadow: 0 14px 30px rgba(232, 62, 140, 0.22) !important;
}
.seo-project-name-modal-actions { .seo-project-name-modal-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@ -14297,7 +14964,7 @@ body:has(.seo-launcher-shell) {
color: rgba(8, 8, 10, 0.72); color: rgba(8, 8, 10, 0.72);
} }
.seo-stage-5 .wordstat-quota-strip { .seo-stage-5 .seo-work-panel-toolbar > .wordstat-quota-strip {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@ -14305,6 +14972,76 @@ body:has(.seo-launcher-shell) {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.nodedc-global-wordstat-quota {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
}
.nodedc-global-wordstat-quota .wordstat-quota-strip {
width: clamp(14rem, 22vw, 19rem);
min-width: 12.5rem;
}
.keyword-context-toolbar {
display: inline-grid;
grid-template-columns: minmax(12rem, 17rem) 2.42rem;
align-items: center;
gap: 0.38rem;
min-width: 0;
}
.keyword-context-profile-select {
width: 100%;
}
.keyword-context-toolbar .nodedc-select__control {
grid-template-columns: minmax(0, 1fr) 2.42rem;
gap: 0.38rem;
}
.keyword-context-toolbar .nodedc-select__value,
.keyword-context-toolbar .nodedc-select__toggle {
height: 2.42rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.84);
}
.keyword-context-toolbar > button {
display: grid;
width: 2.42rem;
height: 2.42rem;
place-items: center;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.86);
color: rgba(10, 12, 14, 0.78);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 10px 28px rgba(24, 32, 29, 0.08);
cursor: pointer;
}
.keyword-context-toolbar > button:hover:not(:disabled),
.keyword-context-toolbar > button.saving {
background: #e83e8c;
color: #fff;
}
.keyword-context-toolbar > button:disabled {
cursor: not-allowed;
opacity: 0.68;
}
.keyword-context-profile-menu {
min-width: 25rem;
max-width: min(31rem, calc(100vw - 1.5rem));
}
.keyword-context-profile-menu .nodedc-select__option {
min-height: 48px;
padding-inline: 12px 8px;
}
.wordstat-quota-head, .wordstat-quota-head,
.wordstat-quota-meta { .wordstat-quota-meta {
display: flex; display: flex;
@ -14419,8 +15156,30 @@ body:has(.seo-launcher-shell) {
color: #fff; color: #fff;
} }
.seo-topbar-status-pill {
display: inline-flex;
min-height: 2.34rem;
align-items: center;
justify-content: center;
gap: 0.38rem;
padding: 0 0.86rem;
border-radius: 999px;
background: rgba(232, 62, 140, 0.09);
color: #bc2f72;
font-size: 0.8rem;
font-weight: 860;
letter-spacing: 0;
white-space: nowrap;
}
.seo-topbar-status-pill.blocked {
background: rgba(139, 47, 47, 0.07);
color: #8b2f2f;
}
@media (max-width: 1360px) { @media (max-width: 1360px) {
.seo-stage-5 .seo-topbar-keyword-actions > button { .seo-stage-5 .seo-topbar-keyword-actions > button,
.seo-stage-5 .seo-topbar-keyword-actions > .seo-topbar-status-pill {
width: 3rem; width: 3rem;
min-width: 3rem; min-width: 3rem;
padding: 0; padding: 0;
@ -14428,7 +15187,8 @@ body:has(.seo-launcher-shell) {
font-size: 0; font-size: 0;
} }
.seo-stage-5 .seo-topbar-keyword-actions > button svg { .seo-stage-5 .seo-topbar-keyword-actions > button svg,
.seo-stage-5 .seo-topbar-keyword-actions > .seo-topbar-status-pill svg {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
} }
@ -15481,6 +16241,7 @@ body:has(.seo-launcher-shell) {
} }
.seo-launcher-shell.project-open .seo-stage-rail { .seo-launcher-shell.project-open .seo-stage-rail {
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden !important; overflow: hidden !important;
border: 1px solid var(--nodedc-glass-outline) !important; border: 1px solid var(--nodedc-glass-outline) !important;
background: var(--nodedc-glass-panel-surface-strong) !important; background: var(--nodedc-glass-panel-surface-strong) !important;
@ -15511,6 +16272,43 @@ body:has(.seo-launcher-shell) {
z-index: 3; z-index: 3;
} }
.seo-launcher-shell.project-open .seo-stage-rail-list {
min-height: 0;
}
.seo-stage-rail-wordstat {
display: grid;
min-width: 0;
padding: 0.72rem 0.78rem;
border: 1px solid rgba(24, 32, 29, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.76);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 14px 34px rgba(24, 32, 29, 0.08);
}
.seo-stage-rail-wordstat .wordstat-quota-strip {
width: 100%;
min-width: 0;
gap: 0.18rem;
}
.seo-stage-rail-wordstat .wordstat-quota-head {
font-size: 0.62rem;
}
.seo-stage-rail-wordstat .wordstat-quota-meta {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.24rem;
font-size: 0.58rem;
}
.seo-stage-rail-wordstat .wordstat-quota-meta span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.seo-stage-rail-head { .seo-stage-rail-head {
display: grid !important; display: grid !important;
grid-template-columns: minmax(0, 1fr) 2.38rem; grid-template-columns: minmax(0, 1fr) 2.38rem;