feat: add detail surface overlay renderer
This commit is contained in:
parent
54f02be0f5
commit
f40489d4d7
@ -7,6 +7,7 @@ import { useSession } from "next-auth/react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type { DetailUiProtocol } from "@/lib/detail-ui-protocol";
|
import type { DetailUiProtocol } from "@/lib/detail-ui-protocol";
|
||||||
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
|
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
|
||||||
|
import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||||
import {
|
import {
|
||||||
buildDetailTextLayerNodes,
|
buildDetailTextLayerNodes,
|
||||||
buildDetailTextLayerNodesFromReviewPatch,
|
buildDetailTextLayerNodesFromReviewPatch,
|
||||||
@ -6240,22 +6241,20 @@ export function CanvasChatPanel({
|
|||||||
}) => {
|
}) => {
|
||||||
if (!isAgentLayoutMode) return;
|
if (!isAgentLayoutMode) return;
|
||||||
const storeState = useCanvasStore.getState();
|
const storeState = useCanvasStore.getState();
|
||||||
|
if (params.surfaceSnapshot) {
|
||||||
|
replaceDetailTextLayers({
|
||||||
|
nodeId: params.nodeId,
|
||||||
|
drafts: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const imageNode = storeState.getNode(params.nodeId);
|
const imageNode = storeState.getNode(params.nodeId);
|
||||||
if (!imageNode) return;
|
if (!imageNode) return;
|
||||||
const x = Number(imageNode.position?.x || 0);
|
const x = Number(imageNode.position?.x || 0);
|
||||||
const y = Number(imageNode.position?.y || 0);
|
const y = Number(imageNode.position?.y || 0);
|
||||||
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
|
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
|
||||||
const height = Number(imageNode.size?.height || DETAIL_TARGET_HEIGHT);
|
const height = Number(imageNode.size?.height || DETAIL_TARGET_HEIGHT);
|
||||||
const drafts =
|
const drafts: Array<Omit<CanvasNode, "id" | "createdAt" | "updatedAt">> = [];
|
||||||
params.surfaceSnapshot
|
|
||||||
? buildDetailTextLayerNodesFromSurfaceScreen({
|
|
||||||
parentNodeId: params.nodeId,
|
|
||||||
parentPosition: { x, y },
|
|
||||||
parentSize: { width, height },
|
|
||||||
surface: params.surfaceSnapshot,
|
|
||||||
screenIndex: params.screenIndex + 1,
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const finalDrafts =
|
const finalDrafts =
|
||||||
drafts.length > 0
|
drafts.length > 0
|
||||||
? drafts
|
? drafts
|
||||||
@ -6749,6 +6748,10 @@ export function CanvasChatPanel({
|
|||||||
detailScreenReview: screenReviewResult || null,
|
detailScreenReview: screenReviewResult || null,
|
||||||
detailUiSurfaceId: currentDetailSurfaceSnapshot?.surfaceId || null,
|
detailUiSurfaceId: currentDetailSurfaceSnapshot?.surfaceId || null,
|
||||||
detailUiSurfaceType: currentDetailSurfaceSnapshot?.surfaceType || null,
|
detailUiSurfaceType: currentDetailSurfaceSnapshot?.surfaceType || null,
|
||||||
|
detailUiScreenOverlay: buildDetailUiScreenOverlaySnapshot({
|
||||||
|
surface: currentDetailSurfaceSnapshot,
|
||||||
|
screenIndex: index + 1,
|
||||||
|
}),
|
||||||
detailGeneratingPlaceholder: false,
|
detailGeneratingPlaceholder: false,
|
||||||
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,
|
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,
|
||||||
},
|
},
|
||||||
|
|||||||
207
components/canvas/DetailSurfaceOverlayRenderer.tsx
Normal file
207
components/canvas/DetailSurfaceOverlayRenderer.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||||
|
|
||||||
|
type RenderableOverlayComponent =
|
||||||
|
DetailUiScreenOverlaySnapshot["components"][number];
|
||||||
|
|
||||||
|
const OVERLAY_RENDERABLE_TYPES = new Set([
|
||||||
|
"detail_text_block",
|
||||||
|
"detail_card",
|
||||||
|
"detail_badge",
|
||||||
|
"detail_feature_card",
|
||||||
|
"detail_feature_tag",
|
||||||
|
"detail_spec_table",
|
||||||
|
"detail_table_label",
|
||||||
|
"detail_table_value",
|
||||||
|
"detail_comparison_strip",
|
||||||
|
"detail_compare_before",
|
||||||
|
"detail_compare_after",
|
||||||
|
"detail_proof_bar",
|
||||||
|
"detail_cta_banner",
|
||||||
|
"detail_cta_button",
|
||||||
|
"detail_cta_support",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function collectRenderableComponents(snapshot: DetailUiScreenOverlaySnapshot) {
|
||||||
|
return snapshot.components
|
||||||
|
.filter((component) => OVERLAY_RENDERABLE_TYPES.has(component.type))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftZ = Number(left.layout?.zIndex || 0);
|
||||||
|
const rightZ = Number(right.layout?.zIndex || 0);
|
||||||
|
return leftZ - rightZ;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOverlayText(
|
||||||
|
snapshot: DetailUiScreenOverlaySnapshot,
|
||||||
|
component: RenderableOverlayComponent,
|
||||||
|
) {
|
||||||
|
const binding = component.bindings?.content;
|
||||||
|
if (!binding || binding.kind !== "text") {
|
||||||
|
return String(component.props?.content || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = String(binding.path || "").trim();
|
||||||
|
const screen = snapshot.dataModel.screen;
|
||||||
|
if (!path || !screen) {
|
||||||
|
return String(component.props?.content || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.endsWith("/title")) return String(screen.title || "");
|
||||||
|
if (path.endsWith("/subtitle")) return String(screen.subtitle || "");
|
||||||
|
if (path.endsWith("/copy")) return String(screen.copy || "");
|
||||||
|
if (path.endsWith("/layoutNote")) return String(screen.layoutNote || "");
|
||||||
|
|
||||||
|
return String(component.props?.content || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverlayComponentStyle(params: {
|
||||||
|
component: RenderableOverlayComponent;
|
||||||
|
snapshot: DetailUiScreenOverlaySnapshot;
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
const frame = (params.component.layout?.frame || {}) as {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
const style = (params.component.style || {}) as Record<string, unknown>;
|
||||||
|
const widthScale = 100 / Math.max(1, params.snapshot.screenSize.width);
|
||||||
|
const heightScale = 100 / Math.max(1, params.snapshot.screenSize.height);
|
||||||
|
|
||||||
|
const overlayStyle: CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
left: `${Number(frame.x || 0) * widthScale}%`,
|
||||||
|
top: `${Number(frame.y || 0) * heightScale}%`,
|
||||||
|
width: `${Number(frame.width || 0) * widthScale}%`,
|
||||||
|
height: `${Number(frame.height || 0) * heightScale}%`,
|
||||||
|
zIndex: Number(params.component.layout?.zIndex || 1),
|
||||||
|
fontSize: typeof style.fontSize === "number" ? `${style.fontSize}px` : undefined,
|
||||||
|
fontFamily: typeof style.fontFamily === "string" ? style.fontFamily : undefined,
|
||||||
|
fontWeight: typeof style.fontWeight === "string" ? style.fontWeight : undefined,
|
||||||
|
lineHeight: typeof style.lineHeight === "number" ? style.lineHeight : undefined,
|
||||||
|
letterSpacing:
|
||||||
|
typeof style.letterSpacing === "number" ? `${style.letterSpacing}px` : undefined,
|
||||||
|
color: typeof style.color === "string" ? style.color : undefined,
|
||||||
|
textAlign:
|
||||||
|
style.textAlign === "center" || style.textAlign === "right"
|
||||||
|
? style.textAlign
|
||||||
|
: "left",
|
||||||
|
textTransform:
|
||||||
|
style.textTransform === "uppercase" ||
|
||||||
|
style.textTransform === "lowercase" ||
|
||||||
|
style.textTransform === "capitalize"
|
||||||
|
? style.textTransform
|
||||||
|
: undefined,
|
||||||
|
background: typeof style.backgroundColor === "string" ? style.backgroundColor : undefined,
|
||||||
|
borderColor: typeof style.borderColor === "string" ? style.borderColor : undefined,
|
||||||
|
borderWidth:
|
||||||
|
typeof style.borderWidth === "number" ? `${style.borderWidth}px` : undefined,
|
||||||
|
borderStyle:
|
||||||
|
typeof style.borderWidth === "number" && style.borderWidth > 0 ? "solid" : undefined,
|
||||||
|
borderRadius:
|
||||||
|
typeof style.borderRadius === "number" ? `${style.borderRadius}px` : undefined,
|
||||||
|
padding: typeof style.padding === "number" ? `${style.padding}px` : undefined,
|
||||||
|
boxShadow: typeof style.boxShadow === "string" ? style.boxShadow : undefined,
|
||||||
|
backdropFilter:
|
||||||
|
typeof style.backdropBlur === "number" ? `blur(${style.backdropBlur}px)` : undefined,
|
||||||
|
WebkitBackdropFilter:
|
||||||
|
typeof style.backdropBlur === "number" ? `blur(${style.backdropBlur}px)` : undefined,
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent:
|
||||||
|
style.textAlign === "center"
|
||||||
|
? "center"
|
||||||
|
: style.textAlign === "right"
|
||||||
|
? "flex-end"
|
||||||
|
: "flex-start",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
params.component.type === "detail_feature_card" ||
|
||||||
|
params.component.type === "detail_spec_table" ||
|
||||||
|
params.component.type === "detail_compare_before" ||
|
||||||
|
params.component.type === "detail_compare_after" ||
|
||||||
|
params.component.type === "detail_proof_bar" ||
|
||||||
|
params.component.type === "detail_cta_banner" ||
|
||||||
|
params.component.type === "detail_cta_support"
|
||||||
|
) {
|
||||||
|
overlayStyle.alignItems = "flex-start";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.component.type === "detail_cta_button") {
|
||||||
|
overlayStyle.justifyContent = "center";
|
||||||
|
overlayStyle.cursor = "pointer";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.component.type === "detail_table_label") {
|
||||||
|
overlayStyle.justifyContent = "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
return overlayStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailSurfaceOverlayRenderer(params: {
|
||||||
|
overlay: DetailUiScreenOverlaySnapshot | null;
|
||||||
|
imageMetrics?: {
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
renderedWidth: number;
|
||||||
|
renderedHeight: number;
|
||||||
|
} | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!params.overlay) return null;
|
||||||
|
|
||||||
|
const components = collectRenderableComponents(params.overlay);
|
||||||
|
if (components.length === 0) return null;
|
||||||
|
|
||||||
|
const containerStyle =
|
||||||
|
params.imageMetrics && params.imageMetrics.renderedWidth > 0 && params.imageMetrics.renderedHeight > 0
|
||||||
|
? {
|
||||||
|
position: "absolute" as const,
|
||||||
|
left: `${params.imageMetrics.offsetX}px`,
|
||||||
|
top: `${params.imageMetrics.offsetY}px`,
|
||||||
|
width: `${params.imageMetrics.renderedWidth}px`,
|
||||||
|
height: `${params.imageMetrics.renderedHeight}px`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: "absolute" as const,
|
||||||
|
inset: "0px",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("pointer-events-none z-[120]", params.className)}
|
||||||
|
data-canvas-no-drag="true"
|
||||||
|
style={containerStyle}
|
||||||
|
>
|
||||||
|
{components.map((component) => {
|
||||||
|
const content = resolveOverlayText(params.overlay!, component);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={component.id}
|
||||||
|
className={cn(
|
||||||
|
"select-none",
|
||||||
|
component.type === "detail_cta_button" && "pointer-events-auto",
|
||||||
|
)}
|
||||||
|
style={getOverlayComponentStyle({
|
||||||
|
component,
|
||||||
|
snapshot: params.overlay!,
|
||||||
|
content,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -35,7 +35,9 @@ import { AngleEditDialog } from "@/components/angle-edit-dialog";
|
|||||||
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||||
import { ImageAnnotator } from "@/components/image-annotator";
|
import { ImageAnnotator } from "@/components/image-annotator";
|
||||||
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
|
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
|
||||||
|
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||||||
import type { PickedObject } from "@/store/canvas";
|
import type { PickedObject } from "@/store/canvas";
|
||||||
|
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||||
|
|
||||||
// 已标注的物品标记(本地状态)
|
// 已标注的物品标记(本地状态)
|
||||||
interface PickedMarker {
|
interface PickedMarker {
|
||||||
@ -367,6 +369,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
typeof data.metadata?.agentExecutionSnapshotId === "string"
|
typeof data.metadata?.agentExecutionSnapshotId === "string"
|
||||||
? data.metadata.agentExecutionSnapshotId.trim()
|
? data.metadata.agentExecutionSnapshotId.trim()
|
||||||
: "";
|
: "";
|
||||||
|
const detailUiScreenOverlay = useMemo(() => {
|
||||||
|
const raw = data.metadata?.detailUiScreenOverlay;
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
|
return raw as DetailUiScreenOverlaySnapshot;
|
||||||
|
}, [data.metadata]);
|
||||||
|
const shouldRenderDetailOverlay =
|
||||||
|
data.metadata?.detailGenerationMode === "agent_layout" && Boolean(detailUiScreenOverlay);
|
||||||
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
|
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
|
||||||
? agentExecutionSnapshotId.slice(0, 8)
|
? agentExecutionSnapshotId.slice(0, 8)
|
||||||
: "";
|
: "";
|
||||||
@ -2502,31 +2511,39 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<>
|
||||||
ref={displayImageRef}
|
<img
|
||||||
src={displayImageSrc}
|
ref={displayImageRef}
|
||||||
alt={displayTitle}
|
src={displayImageSrc}
|
||||||
className={cn(
|
alt={displayTitle}
|
||||||
"w-full h-full object-contain bg-muted/30",
|
className={cn(
|
||||||
boxCutoutMode ? "cursor-crosshair" : isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
|
"w-full h-full object-contain bg-muted/30",
|
||||||
)}
|
boxCutoutMode ? "cursor-crosshair" : isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
|
||||||
draggable={false}
|
)}
|
||||||
loading="lazy"
|
draggable={false}
|
||||||
decoding="async"
|
loading="lazy"
|
||||||
onLoad={refreshBoxCutoutMetrics}
|
decoding="async"
|
||||||
onError={(event) => {
|
onLoad={refreshBoxCutoutMetrics}
|
||||||
if (event.currentTarget.src !== data.src) {
|
onError={(event) => {
|
||||||
event.currentTarget.src = data.src;
|
if (event.currentTarget.src !== data.src) {
|
||||||
}
|
event.currentTarget.src = data.src;
|
||||||
}}
|
}
|
||||||
onClick={isThisNodePicking && !boxCutoutMode ? handlePickerClick : undefined}
|
}}
|
||||||
onDoubleClick={(e) => {
|
onClick={isThisNodePicking && !boxCutoutMode ? handlePickerClick : undefined}
|
||||||
if (!isThisNodePicking && !boxCutoutMode) {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation();
|
if (!isThisNodePicking && !boxCutoutMode) {
|
||||||
setFullscreenPreviewOpen(true);
|
e.stopPropagation();
|
||||||
}
|
setFullscreenPreviewOpen(true);
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
{shouldRenderDetailOverlay ? (
|
||||||
|
<DetailSurfaceOverlayRenderer
|
||||||
|
overlay={detailUiScreenOverlay}
|
||||||
|
imageMetrics={boxCutoutMetrics}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{boxCutoutMode && !isThisNodePicking && !isCompareMode && (
|
{boxCutoutMode && !isThisNodePicking && !isCompareMode && (
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import type {
|
|||||||
UiSurfacePatch,
|
UiSurfacePatch,
|
||||||
UiSurfaceSnapshot,
|
UiSurfaceSnapshot,
|
||||||
} from "@/lib/ui-surface-protocol";
|
} from "@/lib/ui-surface-protocol";
|
||||||
|
import {
|
||||||
|
collectUiSurfaceDescendantIds,
|
||||||
|
getUiSurfaceChildren,
|
||||||
|
} from "@/lib/ui-surface-protocol";
|
||||||
|
|
||||||
function createDetailSurfaceId(params: {
|
function createDetailSurfaceId(params: {
|
||||||
generationMode: DetailGenerationMode;
|
generationMode: DetailGenerationMode;
|
||||||
@ -974,6 +978,70 @@ function buildDetailSurfaceDataModel(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DetailUiScreenOverlaySnapshot {
|
||||||
|
version: "detail-screen-overlay/v1";
|
||||||
|
surfaceId: string;
|
||||||
|
surfaceType: "detail_page";
|
||||||
|
generationMode: DetailGenerationMode;
|
||||||
|
screenIndex: number;
|
||||||
|
screenSize: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
rootComponentId: string;
|
||||||
|
components: UiSurfaceComponent[];
|
||||||
|
dataModel: {
|
||||||
|
globalStyle?: DetailUiGlobalStyle;
|
||||||
|
screen: {
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
copy: string;
|
||||||
|
screenType?: string;
|
||||||
|
objective?: string;
|
||||||
|
layoutNote?: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
validation?: UiSurfaceSnapshot["validation"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDetailUiScreenOverlaySnapshot(params: {
|
||||||
|
surface?: UiSurfaceSnapshot | null;
|
||||||
|
screenIndex: number;
|
||||||
|
}) {
|
||||||
|
const surface = params.surface;
|
||||||
|
if (!surface) return null;
|
||||||
|
const screenId = `detail-screen-${params.screenIndex}`;
|
||||||
|
const screenComponent =
|
||||||
|
surface.components.find((component) => component.id === screenId) || null;
|
||||||
|
if (!screenComponent) return null;
|
||||||
|
|
||||||
|
const descendantIds = collectUiSurfaceDescendantIds(surface, screenId);
|
||||||
|
const keepIds = new Set<string>([screenId, ...descendantIds]);
|
||||||
|
const components = surface.components.filter((component) => keepIds.has(component.id));
|
||||||
|
const screenData = Array.isArray((surface.dataModel as any)?.screens)
|
||||||
|
? (surface.dataModel as any).screens.find(
|
||||||
|
(item: any) => Number(item?.index || 0) === params.screenIndex,
|
||||||
|
) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: "detail-screen-overlay/v1",
|
||||||
|
surfaceId: surface.surfaceId,
|
||||||
|
surfaceType: surface.surfaceType,
|
||||||
|
generationMode: surface.generationMode,
|
||||||
|
screenIndex: params.screenIndex,
|
||||||
|
screenSize: surface.screenSize,
|
||||||
|
rootComponentId: screenId,
|
||||||
|
components,
|
||||||
|
dataModel: {
|
||||||
|
globalStyle: (surface.dataModel as any)?.globalStyle || {},
|
||||||
|
screen: screenData,
|
||||||
|
},
|
||||||
|
validation: surface.validation,
|
||||||
|
} satisfies DetailUiScreenOverlaySnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDetailUiSurfaceSnapshot(params: {
|
export function buildDetailUiSurfaceSnapshot(params: {
|
||||||
surfaceId?: string;
|
surfaceId?: string;
|
||||||
generationMode: DetailGenerationMode;
|
generationMode: DetailGenerationMode;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user