Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Frame, Image as ImageIcon, Layers, Lock, Type, Unlock, X } from "lucide-react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
import { thumbnailImageUrl } from "@/ui/lib/imageDelivery";
|
||||
import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
|
||||
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
|
||||
|
||||
type LayerPanelItem =
|
||||
| {
|
||||
type: "node";
|
||||
node: CanvasNode;
|
||||
}
|
||||
| {
|
||||
type: "booleanGroup";
|
||||
node: CanvasNode;
|
||||
layers: CanvasNode[];
|
||||
}
|
||||
| {
|
||||
type: "group";
|
||||
id: string;
|
||||
nodes: CanvasNode[];
|
||||
layers: CanvasNode[];
|
||||
};
|
||||
|
||||
type Translate = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
export function CanvasGrid({ viewport }: { viewport: CanvasViewport }) {
|
||||
const gridSize = 48 * viewport.k;
|
||||
return (
|
||||
<div
|
||||
className="canvas-grid"
|
||||
style={{
|
||||
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||
backgroundPosition: `${viewport.x % gridSize}px ${viewport.y % gridSize}px`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasGeneratedFilesPanel({
|
||||
nodes,
|
||||
messages,
|
||||
selectedIds,
|
||||
onSelect,
|
||||
onDownload,
|
||||
onClose
|
||||
}: {
|
||||
nodes: CanvasNode[];
|
||||
messages: AgentMessage[];
|
||||
selectedIds: Set<string>;
|
||||
onSelect: (nodeId: string) => void;
|
||||
onDownload: (node: CanvasNode) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const generatedFileURLs = useMemo(() => generatedFileURLSet(messages), [messages]);
|
||||
const files = useMemo(() => nodes.filter((node) => isGeneratedFileNode(node, generatedFileURLs)).reverse(), [generatedFileURLs, nodes]);
|
||||
|
||||
return (
|
||||
<aside className="canvas-generated-files-panel" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<header>
|
||||
<strong>{t("generatedFiles")}</strong>
|
||||
<button type="button" aria-label={t("close")} onClick={onClose}>
|
||||
<X size={19} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="generated-files-list">
|
||||
{files.length === 0 ? (
|
||||
<div className="generated-files-empty">
|
||||
<ImageIcon size={54} />
|
||||
<span>{t("generatedFilesEmpty")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{files.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`generated-file-row ${selectedIds.has(node.id) ? "selected" : ""}`}
|
||||
onClick={() => onSelect(node.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onSelect(node.id);
|
||||
}}
|
||||
>
|
||||
<span className="generated-file-thumb">
|
||||
<img src={thumbnailImageUrl(node.content)} alt={node.title} draggable={false} />
|
||||
</span>
|
||||
<span className="generated-file-name">{layerTitle(node, t)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="generated-file-download"
|
||||
aria-label={t("download")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDownload(node);
|
||||
}}
|
||||
>
|
||||
<Download size={19} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="generated-files-end">{t("generatedFilesEnd")}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasLayerPanel({
|
||||
nodes,
|
||||
selectedIds,
|
||||
hiddenNodeIds,
|
||||
lockedNodeIds,
|
||||
onSelect,
|
||||
onSelectMany,
|
||||
onRenameNode,
|
||||
onToggleHidden,
|
||||
onToggleLocked,
|
||||
onToggleHiddenMany,
|
||||
onToggleLockedMany,
|
||||
onClose
|
||||
}: {
|
||||
nodes: CanvasNode[];
|
||||
selectedIds: Set<string>;
|
||||
hiddenNodeIds: Set<string>;
|
||||
lockedNodeIds: Set<string>;
|
||||
onSelect: (nodeId: string) => void;
|
||||
onSelectMany: (nodeIds: string[]) => void;
|
||||
onRenameNode: (nodeId: string, title: string) => void;
|
||||
onToggleHidden: (nodeId: string) => void;
|
||||
onToggleLocked: (nodeId: string) => void;
|
||||
onToggleHiddenMany: (nodeIds: string[]) => void;
|
||||
onToggleLockedMany: (nodeIds: string[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const layers = useMemo(() => buildLayerPanelItems(nodes), [nodes]);
|
||||
|
||||
return (
|
||||
<aside className="canvas-layer-panel" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<header>
|
||||
<strong>{t("layers")}</strong>
|
||||
<button type="button" aria-label={t("close")} onClick={onClose}>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</header>
|
||||
<section className="layer-history-section">
|
||||
<button type="button">
|
||||
<span>{t("layerHistory")}</span>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<div className="layer-history-empty">
|
||||
<ImageIcon size={58} />
|
||||
<span>{t("layerNoHistory")}</span>
|
||||
</div>
|
||||
</section>
|
||||
<div className="layer-list">
|
||||
{layers.length === 0 ? (
|
||||
<div className="layer-list-empty">{t("layerNoLayers")}</div>
|
||||
) : (
|
||||
layers.map((item) => {
|
||||
if (item.type === "booleanGroup") {
|
||||
const selected = selectedIds.has(item.node.id);
|
||||
const hidden = hiddenNodeIds.has(item.node.id);
|
||||
const locked = lockedNodeIds.has(item.node.id);
|
||||
return (
|
||||
<Fragment key={item.node.id}>
|
||||
<div className={`layer-row layer-group-row boolean-layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelect(item.node.id)}>
|
||||
<ChevronDown size={14} />
|
||||
<span className="layer-thumb group-thumb boolean-group-thumb">
|
||||
{layerThumb(item.node)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="layer-name"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(item.node.id);
|
||||
}}
|
||||
>
|
||||
<strong>{layerTitle(item.node, t)}</strong>
|
||||
<small>{t("booleanOperation")}</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`layer-control ${locked ? "active" : ""}`}
|
||||
aria-label={locked ? t("contextUnlock") : t("contextLock")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleLocked(item.node.id);
|
||||
}}
|
||||
>
|
||||
{locked ? <Lock size={15} /> : <Unlock size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`layer-control ${hidden ? "active" : ""}`}
|
||||
aria-label={hidden ? t("contextShow") : t("contextHide")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleHidden(item.node.id);
|
||||
}}
|
||||
>
|
||||
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
{item.layers.map((node) => (
|
||||
<LayerNodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
nested
|
||||
selected={selected}
|
||||
hidden={hiddenNodeIds.has(node.id)}
|
||||
locked={lockedNodeIds.has(node.id)}
|
||||
controlsVisible={false}
|
||||
onSelect={() => onSelect(item.node.id)}
|
||||
onRenameNode={onRenameNode}
|
||||
onToggleHidden={onToggleHidden}
|
||||
onToggleLocked={onToggleLocked}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "group") {
|
||||
const nodeIds = item.nodes.map((node) => node.id);
|
||||
const selected = nodeIds.some((nodeId) => selectedIds.has(nodeId));
|
||||
const hidden = nodeIds.every((nodeId) => hiddenNodeIds.has(nodeId));
|
||||
const locked = nodeIds.every((nodeId) => lockedNodeIds.has(nodeId));
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
<div className={`layer-row layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelectMany(nodeIds)}>
|
||||
<span className="layer-thumb group-thumb">
|
||||
<Layers size={17} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="layer-name"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectMany(nodeIds);
|
||||
}}
|
||||
>
|
||||
<strong>{t("layerGroup")}</strong>
|
||||
<small>{t("layerItemsCount", { count: item.nodes.length })}</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`layer-control ${locked ? "active" : ""}`}
|
||||
aria-label={locked ? t("contextUnlock") : t("contextLock")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleLockedMany(nodeIds);
|
||||
}}
|
||||
>
|
||||
{locked ? <Lock size={15} /> : <Unlock size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`layer-control ${hidden ? "active" : ""}`}
|
||||
aria-label={hidden ? t("contextShow") : t("contextHide")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleHiddenMany(nodeIds);
|
||||
}}
|
||||
>
|
||||
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
{item.layers.map((node) => (
|
||||
<LayerNodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
nested
|
||||
selected={selectedIds.has(node.id)}
|
||||
hidden={hiddenNodeIds.has(node.id)}
|
||||
locked={lockedNodeIds.has(node.id)}
|
||||
onSelect={onSelect}
|
||||
onRenameNode={onRenameNode}
|
||||
onToggleHidden={onToggleHidden}
|
||||
onToggleLocked={onToggleLocked}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const node = item.node;
|
||||
return (
|
||||
<LayerNodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
selected={selectedIds.has(node.id)}
|
||||
hidden={hiddenNodeIds.has(node.id)}
|
||||
locked={lockedNodeIds.has(node.id)}
|
||||
onSelect={onSelect}
|
||||
onRenameNode={onRenameNode}
|
||||
onToggleHidden={onToggleHidden}
|
||||
onToggleLocked={onToggleLocked}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerNodeRow({
|
||||
node,
|
||||
selected,
|
||||
hidden,
|
||||
locked,
|
||||
nested = false,
|
||||
onSelect,
|
||||
onRenameNode,
|
||||
onToggleHidden,
|
||||
onToggleLocked,
|
||||
controlsVisible = true
|
||||
}: {
|
||||
node: CanvasNode;
|
||||
selected: boolean;
|
||||
hidden: boolean;
|
||||
locked: boolean;
|
||||
nested?: boolean;
|
||||
onSelect: (nodeId: string) => void;
|
||||
onRenameNode: (nodeId: string, title: string) => void;
|
||||
onToggleHidden: (nodeId: string) => void;
|
||||
onToggleLocked: (nodeId: string) => void;
|
||||
controlsVisible?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [draftName, setDraftName] = useState(node.title);
|
||||
const title = layerTitle(node, t);
|
||||
const canRename = node.type === "image" && !locked;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingName) setDraftName(node.title);
|
||||
}, [editingName, node.title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingName) return;
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.select();
|
||||
}, [editingName]);
|
||||
|
||||
const startEditingName = () => {
|
||||
if (!canRename) return;
|
||||
setDraftName(node.title.trim() || title);
|
||||
setEditingName(true);
|
||||
};
|
||||
|
||||
const cancelEditingName = () => {
|
||||
setDraftName(node.title);
|
||||
setEditingName(false);
|
||||
};
|
||||
|
||||
const commitEditingName = () => {
|
||||
const nextName = draftName.trim();
|
||||
if (!nextName || nextName === node.title.trim()) {
|
||||
cancelEditingName();
|
||||
return;
|
||||
}
|
||||
onRenameNode(node.id, nextName);
|
||||
setEditingName(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`layer-row ${nested ? "nested" : ""} ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelect(node.id)}>
|
||||
<span className="layer-thumb">{layerThumb(node)}</span>
|
||||
{editingName ? (
|
||||
<span className="layer-name layer-name-editing" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftName}
|
||||
placeholder={t("imageTitlePlaceholder")}
|
||||
aria-label={t("renameImageNode")}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onBlur={commitEditingName}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitEditingName();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelEditingName();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="layer-name"
|
||||
aria-label={canRename ? t("renameImageNode") : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(node.id);
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditingName();
|
||||
}}
|
||||
>
|
||||
<strong>{title}</strong>
|
||||
<small>{layerKindLabel(node, t)}</small>
|
||||
</button>
|
||||
)}
|
||||
{controlsVisible ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`layer-control ${locked ? "active" : ""}`}
|
||||
aria-label={locked ? t("contextUnlock") : t("contextLock")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleLocked(node.id);
|
||||
}}
|
||||
>
|
||||
{locked ? <Lock size={15} /> : <Unlock size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`layer-control ${hidden ? "active" : ""}`}
|
||||
aria-label={hidden ? t("contextShow") : t("contextHide")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleHidden(node.id);
|
||||
}}
|
||||
>
|
||||
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="layer-controls-placeholder" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] {
|
||||
const groups = new Map<string, CanvasNode[]>();
|
||||
nodes.forEach((node) => {
|
||||
if (!node.parentId) return;
|
||||
groups.set(node.parentId, [...(groups.get(node.parentId) ?? []), node]);
|
||||
});
|
||||
|
||||
const emittedGroups = new Set<string>();
|
||||
const items: LayerPanelItem[] = [];
|
||||
[...nodes].reverse().forEach((node) => {
|
||||
if (isBooleanShapeOperandNode(node, nodes)) return;
|
||||
if (isBooleanShapeGroupNode(node)) {
|
||||
items.push({
|
||||
type: "booleanGroup",
|
||||
node,
|
||||
layers: [...(groups.get(node.id) ?? [])].reverse()
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!node.parentId) {
|
||||
items.push({ type: "node", node });
|
||||
return;
|
||||
}
|
||||
if (emittedGroups.has(node.parentId)) return;
|
||||
const groupNodes = groups.get(node.parentId) ?? [node];
|
||||
emittedGroups.add(node.parentId);
|
||||
items.push({
|
||||
type: "group",
|
||||
id: node.parentId,
|
||||
nodes: groupNodes,
|
||||
layers: [...groupNodes].reverse()
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function layerTitle(node: CanvasNode, t: Translate) {
|
||||
const title = node.title.trim();
|
||||
if (title) return title;
|
||||
if (node.type === "text") return node.content.trim().slice(0, 24) || t("textNode");
|
||||
if (node.type === "frame" && node.layerRole === "shape") return t("shapeTool");
|
||||
if (node.type === "frame") return t("frameNode");
|
||||
if (node.type === "image") return t("imageNode");
|
||||
return t("canvasUntitled");
|
||||
}
|
||||
|
||||
function layerKindLabel(node: CanvasNode, t: Translate) {
|
||||
if (node.type === "text") return t("textNode");
|
||||
if (node.type === "frame" && node.layerRole === "shape") return t("shapeTool");
|
||||
if (node.type === "frame") return t("frameNode");
|
||||
if (node.type === "image") return t("imageNode");
|
||||
return node.type;
|
||||
}
|
||||
|
||||
function layerThumb(node: CanvasNode) {
|
||||
if (isBooleanShapeGroupNode(node) || isShapeNode(node)) {
|
||||
return <ShapeNodePreview node={node} />;
|
||||
}
|
||||
if (node.type === "image" && isImageUrl(node.content)) {
|
||||
return <img src={thumbnailImageUrl(node.content)} alt="" draggable={false} />;
|
||||
}
|
||||
if (node.type === "text") return <Type size={17} />;
|
||||
if (node.type === "frame") return <Frame size={17} />;
|
||||
return <ImageIcon size={17} />;
|
||||
}
|
||||
|
||||
function isGeneratedFileNode(node: CanvasNode, generatedFileURLs: Set<string>) {
|
||||
return (
|
||||
node.type === "image" &&
|
||||
isImageUrl(node.content) &&
|
||||
node.status !== "generating" &&
|
||||
node.status !== "uploading" &&
|
||||
node.status !== "error" &&
|
||||
!nonModelGeneratedLayerRoles.has(node.layerRole ?? "") &&
|
||||
assetURLSetHas(generatedFileURLs, node.content)
|
||||
);
|
||||
}
|
||||
|
||||
function isImageUrl(value: string) {
|
||||
return /^(https?:\/\/|data:image\/|blob:|\/)/.test(value.trim());
|
||||
}
|
||||
|
||||
const nonModelGeneratedLayerRoles = new Set(["pen-stroke", "background-removed", "clean-image", "mockup-print", "merge-layer", "manual-frame", "image-generator"]);
|
||||
|
||||
function generatedFileURLSet(messages: AgentMessage[]) {
|
||||
const urls = new Set<string>();
|
||||
messages.forEach((message) => {
|
||||
if (message.name !== "generate_media" && message.title !== "generate_media" && message.type !== "tool_use") return;
|
||||
try {
|
||||
const events = JSON.parse(message.content) as Array<{
|
||||
event_data?: {
|
||||
artifact?: Array<{
|
||||
image_url?: string;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
events.forEach((event) => {
|
||||
event.event_data?.artifact?.forEach((artifact) => {
|
||||
if (artifact.image_url) addAssetURLKeys(urls, artifact.image_url);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Tool messages that are not artifact payloads are ignored here.
|
||||
}
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
function assetURLSetHas(items: Set<string>, value: string) {
|
||||
return assetURLKeys(value).some((key) => items.has(key));
|
||||
}
|
||||
|
||||
function addAssetURLKeys(items: Set<string>, value: string) {
|
||||
assetURLKeys(value).forEach((key) => items.add(key));
|
||||
}
|
||||
|
||||
function assetURLKeys(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.startsWith("data:image/") || trimmed.startsWith("blob:")) return [];
|
||||
const keys = new Set<string>([trimmed]);
|
||||
const queryIndex = trimmed.indexOf("?");
|
||||
if (queryIndex > 0) keys.add(trimmed.slice(0, queryIndex));
|
||||
try {
|
||||
const url = new URL(trimmed, window.location.origin);
|
||||
keys.add(url.pathname);
|
||||
if (url.search) keys.add(`${url.pathname}${url.search}`);
|
||||
} catch {
|
||||
// Keep exact and queryless keys above.
|
||||
}
|
||||
return Array.from(keys);
|
||||
}
|
||||
Reference in New Issue
Block a user