Fix canvas node models and connections
This commit is contained in:
@@ -850,7 +850,7 @@ function WorkflowTemplateIcon({ icon, className }: { icon: WorkflowTemplateMeta[
|
||||
|
||||
export function InfiniteCanvasWorkspace() {
|
||||
const { user, accessToken } = useAuth();
|
||||
const { imageKeys } = useCustomApiKeys();
|
||||
const { imageKeys, textKeys } = useCustomApiKeys();
|
||||
const managedSystemApis = useManagedSystemApis();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -919,7 +919,8 @@ export function InfiniteCanvasWorkspace() {
|
||||
], [state.assets.length, state.connections.length, state.groups.length, state.nodes.length]);
|
||||
|
||||
const systemImageApis = managedSystemApis.filter(api => api.type === 'image' && api.isActive);
|
||||
const modelOptions = useMemo(() => [
|
||||
const systemTextApis = managedSystemApis.filter(api => api.type === 'text' && api.isActive);
|
||||
const imageModelOptions = useMemo(() => [
|
||||
...systemImageApis.map(api => ({
|
||||
id: buildSystemModelId(api.id),
|
||||
label: `${api.name} (系统)`,
|
||||
@@ -933,6 +934,20 @@ export function InfiniteCanvasWorkspace() {
|
||||
source: 'custom' as const,
|
||||
})),
|
||||
], [systemImageApis, imageKeys]);
|
||||
const textModelOptions = useMemo(() => [
|
||||
...systemTextApis.map(api => ({
|
||||
id: buildSystemModelId(api.id),
|
||||
label: `${api.name} (系统)`,
|
||||
modelName: api.modelName,
|
||||
source: 'system' as const,
|
||||
})),
|
||||
...textKeys.map(key => ({
|
||||
id: buildCustomModelId(key.id),
|
||||
label: `${key.modelName || key.provider} (自定义)`,
|
||||
modelName: key.modelName,
|
||||
source: 'custom' as const,
|
||||
})),
|
||||
], [systemTextApis, textKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -1644,29 +1659,47 @@ export function InfiniteCanvasWorkspace() {
|
||||
toast.info('不能连接到同一个模块');
|
||||
return;
|
||||
}
|
||||
let added = false;
|
||||
let referenceApplied = false;
|
||||
mutateState(current => {
|
||||
const source = current.nodes.find(node => node.id === sourceNodeId);
|
||||
const target = current.nodes.find(node => node.id === targetNodeId);
|
||||
if (!source || !target) return current;
|
||||
const exists = current.connections.some(
|
||||
connection => connection.sourceNodeId === sourceNodeId && connection.targetNodeId === targetNodeId,
|
||||
);
|
||||
if (exists) return current;
|
||||
if (exists) {
|
||||
referenceApplied = !!getNodeImageUrl(source) && target.type === 'img2img' && target.referenceImage !== getNodeImageUrl(source);
|
||||
return referenceApplied
|
||||
? {
|
||||
...normalizeCanvasProjectState(current),
|
||||
nodes: current.nodes.map(node => node.id === targetNodeId ? { ...node, referenceImage: getNodeImageUrl(source), updatedAt: nowIso() } : node),
|
||||
}
|
||||
: current;
|
||||
}
|
||||
const sourceImage = getNodeImageUrl(source);
|
||||
added = true;
|
||||
referenceApplied = !!sourceImage && target.type === 'img2img';
|
||||
const connection: CanvasConnection = {
|
||||
id: createId('connection'),
|
||||
sourceNodeId,
|
||||
targetNodeId,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
return { ...normalizeCanvasProjectState(current), connections: [...current.connections, connection] };
|
||||
return {
|
||||
...normalizeCanvasProjectState(current),
|
||||
nodes: referenceApplied
|
||||
? current.nodes.map(node => node.id === targetNodeId ? { ...node, referenceImage: sourceImage, updatedAt: nowIso() } : node)
|
||||
: current.nodes,
|
||||
connections: [...current.connections, connection],
|
||||
};
|
||||
});
|
||||
const source = state.nodes.find(node => node.id === sourceNodeId);
|
||||
const target = state.nodes.find(node => node.id === targetNodeId);
|
||||
const sourceImage = getNodeImageUrl(source);
|
||||
if (sourceImage && target?.type === 'img2img') {
|
||||
updateNode(target.id, { referenceImage: sourceImage });
|
||||
if (!options?.silent) toast.success('已连接,并将图片设为图生图参考');
|
||||
} else if (!options?.silent) {
|
||||
toast.success('已连接模块');
|
||||
if (!options?.silent) {
|
||||
if (added && referenceApplied) toast.success('已连接,并将图片设为图生图参考');
|
||||
else if (added) toast.success('已连接模块');
|
||||
else toast.info('这两个模块已经连接');
|
||||
}
|
||||
}, [mutateState, state.nodes, updateNode]);
|
||||
}, [mutateState]);
|
||||
|
||||
const createVariationNodeFromCanvas = useCallback((sourceNodeId: string) => {
|
||||
const source = state.nodes.find(node => node.id === sourceNodeId);
|
||||
@@ -1961,7 +1994,7 @@ export function InfiniteCanvasWorkspace() {
|
||||
const prompt = incomingPromptText
|
||||
? [incomingPromptText, basePrompt].filter(Boolean).join('\n\n')
|
||||
: basePrompt;
|
||||
const selectedModel = node.params?.model || modelOptions[0]?.id || '';
|
||||
const selectedModel = node.params?.model || imageModelOptions[0]?.id || '';
|
||||
const shouldUseReferenceImage = node.type === 'img2img' || !!referenceImage;
|
||||
const rawAspectRatio = node.params?.aspectRatio;
|
||||
const aspectRatio = rawAspectRatio && (shouldUseReferenceImage || rawAspectRatio !== 'original')
|
||||
@@ -2010,7 +2043,7 @@ export function InfiniteCanvasWorkspace() {
|
||||
}
|
||||
|
||||
return payload;
|
||||
}, [imageKeys, modelOptions, state, systemImageApis]);
|
||||
}, [imageKeys, imageModelOptions, state, systemImageApis]);
|
||||
|
||||
const generateForNode = useCallback(async (node: CanvasNode, options?: { canvasState?: CanvasProjectState; selectOutput?: boolean }) => {
|
||||
const canvasState = options?.canvasState || state;
|
||||
@@ -2473,7 +2506,8 @@ export function InfiniteCanvasWorkspace() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [copySelectedNodes, cutSelectedNodes, duplicateSelectedNode, pasteClipboardNodes, redoCanvas, removeSelectedNode, saveProject, state.nodes, undoCanvas]);
|
||||
|
||||
const activeModelValue = selectedNode?.params?.model || modelOptions[0]?.id || '';
|
||||
const selectedNodeModelOptions = selectedNode?.type === 'text' ? textModelOptions : imageModelOptions;
|
||||
const activeModelValue = selectedNode?.params?.model || selectedNodeModelOptions[0]?.id || '';
|
||||
const selectedNodeCanGenerate = isRunnableGenerationNode(selectedNode);
|
||||
const selectedNodeHasIncomingImage = !!selectedNode && state.connections
|
||||
.filter(connection => connection.targetNodeId === selectedNode.id)
|
||||
@@ -2926,7 +2960,8 @@ export function InfiniteCanvasWorkspace() {
|
||||
<ReactFlowCanvas
|
||||
nodes={state.nodes}
|
||||
connections={state.connections}
|
||||
modelOptions={modelOptions.map(option => ({ id: option.id, label: option.label }))}
|
||||
imageModelOptions={imageModelOptions.map(option => ({ id: option.id, label: option.label }))}
|
||||
textModelOptions={textModelOptions.map(option => ({ id: option.id, label: option.label }))}
|
||||
viewport={state.viewport}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
connectingFromId={connectingFromId}
|
||||
@@ -3236,6 +3271,18 @@ export function InfiniteCanvasWorkspace() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedNode.type === 'text' ? (
|
||||
<div>
|
||||
<Label>多模态模型</Label>
|
||||
<Select value={activeModelValue} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, model: value } })}>
|
||||
<SelectTrigger><SelectValue placeholder="选择模型" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{textModelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedNodeCanGenerate ? (
|
||||
<>
|
||||
<div>
|
||||
@@ -3243,7 +3290,7 @@ export function InfiniteCanvasWorkspace() {
|
||||
<Select value={activeModelValue} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, model: value } })}>
|
||||
<SelectTrigger><SelectValue placeholder="选择模型" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
|
||||
{selectedNodeModelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -3295,12 +3342,12 @@ export function InfiniteCanvasWorkspace() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button className="w-full gap-2" onClick={() => void generateForNode(selectedNode)} disabled={selectedNode.status === 'generating' || modelOptions.length === 0}>
|
||||
<Button className="w-full gap-2" onClick={() => void generateForNode(selectedNode)} disabled={selectedNode.status === 'generating' || imageModelOptions.length === 0}>
|
||||
{selectedNode.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
生成
|
||||
</Button>
|
||||
{state.connections.some(connection => connection.sourceNodeId === selectedNode.id) ? (
|
||||
<Button variant="outline" className="w-full gap-2" onClick={() => void runDownstream(selectedNode)} disabled={selectedNode.status === 'generating' || modelOptions.length === 0}>
|
||||
<Button variant="outline" className="w-full gap-2" onClick={() => void runDownstream(selectedNode)} disabled={selectedNode.status === 'generating' || imageModelOptions.length === 0}>
|
||||
<Wand2 className="h-4 w-4" /> 运行本模块及下游
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -37,7 +37,8 @@ type CanvasFlowNodeData = {
|
||||
connecting: boolean;
|
||||
connections: CanvasConnection[];
|
||||
allNodes: CanvasNode[];
|
||||
modelOptions: CanvasModelOption[];
|
||||
imageModelOptions: CanvasModelOption[];
|
||||
textModelOptions: CanvasModelOption[];
|
||||
layerCanvasSize: number;
|
||||
layerColors: Record<CanvasLayer['type'], string>;
|
||||
onSelect: (id: string, additive: boolean) => void;
|
||||
@@ -56,7 +57,8 @@ type CanvasFlowNode = FlowNode<CanvasFlowNodeData, 'canvasNode'>;
|
||||
type ReactFlowCanvasProps = {
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
modelOptions: CanvasModelOption[];
|
||||
imageModelOptions: CanvasModelOption[];
|
||||
textModelOptions: CanvasModelOption[];
|
||||
viewport: CanvasViewport;
|
||||
selectedNodeIds: string[];
|
||||
connectingFromId: string | null;
|
||||
@@ -190,12 +192,12 @@ function NodeCountControl({
|
||||
function NodeInlineParams({
|
||||
node,
|
||||
hasReference,
|
||||
modelOptions,
|
||||
imageModelOptions,
|
||||
onUpdateNode,
|
||||
}: {
|
||||
node: CanvasNode;
|
||||
hasReference: boolean;
|
||||
modelOptions: CanvasModelOption[];
|
||||
imageModelOptions: CanvasModelOption[];
|
||||
onUpdateNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||
}) {
|
||||
const aspectOptions = node.type === 'text2img' || (node.type === 'image' && !hasReference)
|
||||
@@ -207,18 +209,18 @@ function NodeInlineParams({
|
||||
const resolution = node.params?.resolution && RESOLUTION_OPTIONS.includes(node.params.resolution)
|
||||
? node.params.resolution
|
||||
: '2K';
|
||||
const modelValue = node.params?.model || modelOptions[0]?.id || '';
|
||||
const modelValue = node.params?.model || imageModelOptions[0]?.id || '';
|
||||
return (
|
||||
<div className="nodrag mt-2 grid grid-cols-[minmax(0,1.2fr)_72px_72px] gap-2">
|
||||
<select
|
||||
className="h-7 min-w-0 rounded-md border bg-muted/30 px-2 text-[11px] text-foreground outline-none transition-colors focus:border-primary focus:bg-background disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={modelValue}
|
||||
title="生成模型"
|
||||
disabled={modelOptions.length === 0}
|
||||
disabled={imageModelOptions.length === 0}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onChange={(event) => onUpdateNode(node.id, { params: { ...node.params, model: event.target.value } })}
|
||||
>
|
||||
{modelOptions.length > 0 ? modelOptions.map(option => (
|
||||
{imageModelOptions.length > 0 ? imageModelOptions.map(option => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
)) : <option value="">无可用模型</option>}
|
||||
</select>
|
||||
@@ -249,11 +251,10 @@ function NodeInlineParams({
|
||||
}
|
||||
|
||||
function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
|
||||
const { node, selected, connecting, connections, allNodes, modelOptions, layerCanvasSize, layerColors, onSelect, onStartConnect, onUpdateNode, onRunNode, onRunDownstreamNode, onCreateVariationNode, onPreviewNode, onDownloadNode, onRemoveConnection } = data;
|
||||
const { node, selected, connecting, connections, allNodes, imageModelOptions, textModelOptions, layerCanvasSize, layerColors, onSelect, onStartConnect, onUpdateNode, onRunNode, onRunDownstreamNode, onCreateVariationNode, onPreviewNode, onDownloadNode } = data;
|
||||
const incomingNodes = allNodes.filter(item => connections.some(connection => connection.targetNodeId === node.id && connection.sourceNodeId === item.id));
|
||||
const incomingImage = incomingNodes.find(item => !!getNodeImageUrl(item));
|
||||
const incomingText = incomingNodes.map(getNodeTextValue).filter(Boolean).join('\n');
|
||||
const nodeConnections = connections.filter(connection => connection.sourceNodeId === node.id || connection.targetNodeId === node.id);
|
||||
const hasOutgoing = connections.some(connection => connection.sourceNodeId === node.id);
|
||||
const meta = NODE_TYPE_META[node.type];
|
||||
const [editingText, setEditingText] = useState(false);
|
||||
@@ -362,35 +363,49 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
|
||||
|
||||
<div className="h-[calc(100%-2.875rem-0.375rem)] p-3">
|
||||
{node.type === 'text' ? (
|
||||
editingText ? (
|
||||
<textarea
|
||||
className="nodrag nowheel h-full w-full resize-none rounded-md border border-primary/40 bg-background p-3 text-sm leading-6 outline-none ring-2 ring-primary/20"
|
||||
value={node.text || ''}
|
||||
autoFocus
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
{editingText ? (
|
||||
<textarea
|
||||
className="nodrag nowheel min-h-0 flex-1 resize-none rounded-md border border-primary/40 bg-background p-3 text-sm leading-6 outline-none ring-2 ring-primary/20"
|
||||
value={node.text || ''}
|
||||
autoFocus
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => onUpdateNode(node.id, { text: event.target.value })}
|
||||
onBlur={() => setEditingText(false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setEditingText(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="nodrag min-h-0 flex-1 whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-left text-sm leading-6 text-foreground transition-colors hover:bg-muted/50"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditingText(true);
|
||||
}}
|
||||
>
|
||||
{node.text || '双击输入文本'}
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
className="nodrag h-7 shrink-0 rounded-md border bg-muted/30 px-2 text-[11px] text-foreground outline-none transition-colors focus:border-primary focus:bg-background disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={node.params?.model || textModelOptions[0]?.id || ''}
|
||||
title="多模态模型"
|
||||
disabled={textModelOptions.length === 0}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => onUpdateNode(node.id, { text: event.target.value })}
|
||||
onBlur={() => setEditingText(false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setEditingText(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="nodrag h-full w-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-left text-sm leading-6 text-foreground transition-colors hover:bg-muted/50"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditingText(true);
|
||||
}}
|
||||
onChange={(event) => onUpdateNode(node.id, { params: { ...node.params, model: event.target.value } })}
|
||||
>
|
||||
{node.text || '双击输入文本'}
|
||||
</button>
|
||||
)
|
||||
{textModelOptions.length > 0 ? textModelOptions.map(option => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
)) : <option value="">无可用多模态模型</option>}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.type === 'image' ? (
|
||||
@@ -462,7 +477,7 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
<NodeInlineParams node={node} hasReference={hasReferenceImage} modelOptions={modelOptions} onUpdateNode={onUpdateNode} />
|
||||
<NodeInlineParams node={node} hasReference={hasReferenceImage} imageModelOptions={imageModelOptions} onUpdateNode={onUpdateNode} />
|
||||
<div className="mt-2">
|
||||
{editingNegativePrompt ? (
|
||||
<textarea
|
||||
@@ -525,7 +540,7 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
<NodeInlineParams node={node} hasReference={hasReferenceImage} modelOptions={modelOptions} onUpdateNode={onUpdateNode} />
|
||||
<NodeInlineParams node={node} hasReference={hasReferenceImage} imageModelOptions={imageModelOptions} onUpdateNode={onUpdateNode} />
|
||||
<div className="mt-2">
|
||||
{editingNegativePrompt ? (
|
||||
<textarea
|
||||
@@ -651,24 +666,6 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && nodeConnections.length > 0 ? (
|
||||
<div className="nodrag absolute left-3 top-full z-30 mt-2 w-64 rounded-lg border bg-background/95 p-2 text-xs shadow-lg backdrop-blur">
|
||||
<div className="mb-1 font-medium text-muted-foreground">相关连线</div>
|
||||
<div className="space-y-1">
|
||||
{nodeConnections.slice(0, 4).map(connection => {
|
||||
const source = allNodes.find(item => item.id === connection.sourceNodeId);
|
||||
const target = allNodes.find(item => item.id === connection.targetNodeId);
|
||||
return (
|
||||
<div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border px-2 py-1">
|
||||
<span className="min-w-0 truncate">{source?.title || '未知模块'} → {target?.title || '未知模块'}</span>
|
||||
<button className="text-destructive hover:underline" onClick={() => onRemoveConnection(connection.id)}>删</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -700,7 +697,8 @@ function sameFlowNodes(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
|
||||
&& node.data.connecting === next.data.connecting
|
||||
&& node.data.connections === next.data.connections
|
||||
&& node.data.allNodes === next.data.allNodes
|
||||
&& node.data.modelOptions === next.data.modelOptions
|
||||
&& node.data.imageModelOptions === next.data.imageModelOptions
|
||||
&& node.data.textModelOptions === next.data.textModelOptions
|
||||
&& node.data.onRunNode === next.data.onRunNode
|
||||
&& node.data.onRunDownstreamNode === next.data.onRunDownstreamNode
|
||||
&& node.data.onCreateVariationNode === next.data.onCreateVariationNode
|
||||
@@ -720,7 +718,8 @@ function clampViewport(viewport: FlowViewport, minZoom: number, maxZoom: number)
|
||||
function FlowCanvasInner({
|
||||
nodes,
|
||||
connections,
|
||||
modelOptions,
|
||||
imageModelOptions,
|
||||
textModelOptions,
|
||||
viewport,
|
||||
selectedNodeIds,
|
||||
connectingFromId,
|
||||
@@ -773,7 +772,8 @@ function FlowCanvasInner({
|
||||
connecting: connectingFromId === node.id,
|
||||
connections,
|
||||
allNodes: nodes,
|
||||
modelOptions,
|
||||
imageModelOptions,
|
||||
textModelOptions,
|
||||
layerCanvasSize,
|
||||
layerColors,
|
||||
onSelect: onSelectNode,
|
||||
@@ -790,7 +790,7 @@ function FlowCanvasInner({
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
},
|
||||
})), [connectingFromId, connections, editable, layerCanvasSize, layerColors, modelOptions, nodes, onCreateVariationNode, onDownloadNode, onPreviewNode, onRemoveConnection, onRunDownstreamNode, onRunNode, onSelectNode, onStartConnect, onUpdateNode, selectedNodeIds]);
|
||||
})), [connectingFromId, connections, editable, imageModelOptions, layerCanvasSize, layerColors, nodes, onCreateVariationNode, onDownloadNode, onPreviewNode, onRemoveConnection, onRunDownstreamNode, onRunNode, onSelectNode, onStartConnect, onUpdateNode, selectedNodeIds, textModelOptions]);
|
||||
|
||||
const [flowNodes, setFlowNodes] = useState<CanvasFlowNode[]>(incomingNodes);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user