Add adjustment preset node type validation and enhance render data normalization
- Introduced `adjustmentPresetNodeTypeValidator` for validating new adjustment preset node types. - Added new constants for render output resolutions and formats, including custom dimension constraints. - Implemented normalization functions for render data, ensuring proper validation and error handling. - Updated node creation and update mutations to utilize normalized data for improved consistency.
This commit is contained in:
@@ -2,6 +2,7 @@ import { v, type Validator } from "convex/values";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ADJUSTMENT_NODE_TYPES,
|
ADJUSTMENT_NODE_TYPES,
|
||||||
|
ADJUSTMENT_PRESET_NODE_TYPES,
|
||||||
CANVAS_NODE_TYPES,
|
CANVAS_NODE_TYPES,
|
||||||
PHASE1_CANVAS_NODE_TYPES,
|
PHASE1_CANVAS_NODE_TYPES,
|
||||||
} from "../lib/canvas-node-types";
|
} from "../lib/canvas-node-types";
|
||||||
@@ -21,3 +22,4 @@ function buildNodeTypeUnion<
|
|||||||
export const phase1NodeTypeValidator = buildNodeTypeUnion(PHASE1_CANVAS_NODE_TYPES);
|
export const phase1NodeTypeValidator = buildNodeTypeUnion(PHASE1_CANVAS_NODE_TYPES);
|
||||||
export const nodeTypeValidator = buildNodeTypeUnion(CANVAS_NODE_TYPES);
|
export const nodeTypeValidator = buildNodeTypeUnion(CANVAS_NODE_TYPES);
|
||||||
export const adjustmentNodeTypeValidator = buildNodeTypeUnion(ADJUSTMENT_NODE_TYPES);
|
export const adjustmentNodeTypeValidator = buildNodeTypeUnion(ADJUSTMENT_NODE_TYPES);
|
||||||
|
export const adjustmentPresetNodeTypeValidator = buildNodeTypeUnion(ADJUSTMENT_PRESET_NODE_TYPES);
|
||||||
|
|||||||
164
convex/nodes.ts
164
convex/nodes.ts
@@ -43,13 +43,27 @@ type NodeCreateMutationName =
|
|||||||
| "nodes.createWithEdgeToTarget";
|
| "nodes.createWithEdgeToTarget";
|
||||||
|
|
||||||
const DISALLOWED_ADJUSTMENT_DATA_KEYS = [
|
const DISALLOWED_ADJUSTMENT_DATA_KEYS = [
|
||||||
"storageId",
|
|
||||||
"url",
|
|
||||||
"blob",
|
"blob",
|
||||||
"blobUrl",
|
"blobUrl",
|
||||||
"imageData",
|
"imageData",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const DISALLOWED_NON_RENDER_ADJUSTMENT_DATA_KEYS = [
|
||||||
|
"storageId",
|
||||||
|
"url",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RENDER_OUTPUT_RESOLUTIONS = ["original", "2x", "custom"] as const;
|
||||||
|
const RENDER_FORMATS = ["png", "jpeg", "webp"] as const;
|
||||||
|
const CUSTOM_RENDER_DIMENSION_MIN = 1;
|
||||||
|
const CUSTOM_RENDER_DIMENSION_MAX = 16384;
|
||||||
|
const DEFAULT_RENDER_OUTPUT_RESOLUTION = "original" as const;
|
||||||
|
const DEFAULT_RENDER_FORMAT = "png" as const;
|
||||||
|
const DEFAULT_RENDER_JPEG_QUALITY = 90;
|
||||||
|
|
||||||
|
type RenderOutputResolution = (typeof RENDER_OUTPUT_RESOLUTIONS)[number];
|
||||||
|
type RenderFormat = (typeof RENDER_FORMATS)[number];
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -69,6 +83,132 @@ function assertNoAdjustmentImagePayload(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nodeType === "render") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of DISALLOWED_NON_RENDER_ADJUSTMENT_DATA_KEYS) {
|
||||||
|
if (key in data) {
|
||||||
|
throw new Error(
|
||||||
|
`Adjustment nodes '${nodeType}' do not allow '${key}' in data.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRenderOutputResolution(value: unknown): RenderOutputResolution {
|
||||||
|
if (value === undefined) {
|
||||||
|
return DEFAULT_RENDER_OUTPUT_RESOLUTION;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value !== "string" ||
|
||||||
|
!RENDER_OUTPUT_RESOLUTIONS.includes(value as RenderOutputResolution)
|
||||||
|
) {
|
||||||
|
throw new Error("Render data 'outputResolution' must be one of: original, 2x, custom.");
|
||||||
|
}
|
||||||
|
return value as RenderOutputResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRenderCustomDimension(fieldName: "customWidth" | "customHeight", value: unknown): number {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(value) ||
|
||||||
|
(value as number) < CUSTOM_RENDER_DIMENSION_MIN ||
|
||||||
|
(value as number) > CUSTOM_RENDER_DIMENSION_MAX
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Render data '${fieldName}' must be an integer between ${CUSTOM_RENDER_DIMENSION_MIN} and ${CUSTOM_RENDER_DIMENSION_MAX}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRenderFormat(value: unknown): RenderFormat {
|
||||||
|
if (value === undefined) {
|
||||||
|
return DEFAULT_RENDER_FORMAT;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || !RENDER_FORMATS.includes(value as RenderFormat)) {
|
||||||
|
throw new Error("Render data 'format' must be one of: png, jpeg, webp.");
|
||||||
|
}
|
||||||
|
return value as RenderFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRenderJpegQuality(value: unknown): number {
|
||||||
|
if (value === undefined) {
|
||||||
|
return DEFAULT_RENDER_JPEG_QUALITY;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 100) {
|
||||||
|
throw new Error("Render data 'jpegQuality' must be an integer between 1 and 100.");
|
||||||
|
}
|
||||||
|
return value as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRenderData(data: unknown): Record<string, unknown> {
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
throw new Error("Render node data must be an object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoAdjustmentImagePayload("render", data);
|
||||||
|
|
||||||
|
const outputResolution = parseRenderOutputResolution(data.outputResolution);
|
||||||
|
|
||||||
|
const normalized: Record<string, unknown> = {
|
||||||
|
outputResolution,
|
||||||
|
format: parseRenderFormat(data.format),
|
||||||
|
jpegQuality: parseRenderJpegQuality(data.jpegQuality),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (outputResolution === "custom") {
|
||||||
|
if (data.customWidth !== undefined) {
|
||||||
|
normalized.customWidth = parseRenderCustomDimension("customWidth", data.customWidth);
|
||||||
|
}
|
||||||
|
if (data.customHeight !== undefined) {
|
||||||
|
normalized.customHeight = parseRenderCustomDimension("customHeight", data.customHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.lastRenderedAt !== undefined) {
|
||||||
|
if (typeof data.lastRenderedAt !== "number" || !Number.isFinite(data.lastRenderedAt)) {
|
||||||
|
throw new Error("Render data 'lastRenderedAt' must be a finite number.");
|
||||||
|
}
|
||||||
|
normalized.lastRenderedAt = data.lastRenderedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.storageId !== undefined) {
|
||||||
|
if (typeof data.storageId !== "string" || data.storageId.length === 0) {
|
||||||
|
throw new Error("Render data 'storageId' must be a non-empty string when provided.");
|
||||||
|
}
|
||||||
|
normalized.storageId = data.storageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.url !== undefined) {
|
||||||
|
if (typeof data.url !== "string" || data.url.length === 0) {
|
||||||
|
throw new Error("Render data 'url' must be a non-empty string when provided.");
|
||||||
|
}
|
||||||
|
normalized.url = data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNodeDataForWrite(
|
||||||
|
nodeType: Doc<"nodes">["type"],
|
||||||
|
data: unknown,
|
||||||
|
): unknown {
|
||||||
|
if (!isAdjustmentNodeType(nodeType)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
throw new Error(`Adjustment node '${nodeType}' data must be an object.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeType === "render") {
|
||||||
|
return normalizeRenderData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoAdjustmentImagePayload(nodeType, data);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getIdempotentNodeCreateResult(
|
async function getIdempotentNodeCreateResult(
|
||||||
@@ -243,7 +383,7 @@ export const create = mutation({
|
|||||||
return existingNodeId;
|
return existingNodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoAdjustmentImagePayload(args.type, args.data);
|
const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
|
||||||
|
|
||||||
const nodeId = await ctx.db.insert("nodes", {
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
@@ -254,7 +394,7 @@ export const create = mutation({
|
|||||||
height: args.height,
|
height: args.height,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
data: args.data,
|
data: normalizedData,
|
||||||
parentId: args.parentId,
|
parentId: args.parentId,
|
||||||
zIndex: args.zIndex,
|
zIndex: args.zIndex,
|
||||||
});
|
});
|
||||||
@@ -313,7 +453,7 @@ export const createWithEdgeSplit = mutation({
|
|||||||
throw new Error("Edge not found");
|
throw new Error("Edge not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoAdjustmentImagePayload(args.type, args.data);
|
const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
|
||||||
|
|
||||||
const nodeId = await ctx.db.insert("nodes", {
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
@@ -324,7 +464,7 @@ export const createWithEdgeSplit = mutation({
|
|||||||
height: args.height,
|
height: args.height,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
data: args.data,
|
data: normalizedData,
|
||||||
parentId: args.parentId,
|
parentId: args.parentId,
|
||||||
zIndex: args.zIndex,
|
zIndex: args.zIndex,
|
||||||
});
|
});
|
||||||
@@ -501,7 +641,7 @@ export const createWithEdgeFromSource = mutation({
|
|||||||
throw new Error("Source node not found");
|
throw new Error("Source node not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoAdjustmentImagePayload(args.type, args.data);
|
const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
|
||||||
|
|
||||||
const nodeId = await ctx.db.insert("nodes", {
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
@@ -512,7 +652,7 @@ export const createWithEdgeFromSource = mutation({
|
|||||||
height: args.height,
|
height: args.height,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
data: args.data,
|
data: normalizedData,
|
||||||
parentId: args.parentId,
|
parentId: args.parentId,
|
||||||
zIndex: args.zIndex,
|
zIndex: args.zIndex,
|
||||||
});
|
});
|
||||||
@@ -577,7 +717,7 @@ export const createWithEdgeToTarget = mutation({
|
|||||||
throw new Error("Target node not found");
|
throw new Error("Target node not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoAdjustmentImagePayload(args.type, args.data);
|
const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
|
||||||
|
|
||||||
const nodeId = await ctx.db.insert("nodes", {
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
@@ -588,7 +728,7 @@ export const createWithEdgeToTarget = mutation({
|
|||||||
height: args.height,
|
height: args.height,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
data: args.data,
|
data: normalizedData,
|
||||||
parentId: args.parentId,
|
parentId: args.parentId,
|
||||||
zIndex: args.zIndex,
|
zIndex: args.zIndex,
|
||||||
});
|
});
|
||||||
@@ -698,8 +838,8 @@ export const updateData = mutation({
|
|||||||
if (!node) throw new Error("Node not found");
|
if (!node) throw new Error("Node not found");
|
||||||
|
|
||||||
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
||||||
assertNoAdjustmentImagePayload(node.type, data);
|
const normalizedData = normalizeNodeDataForWrite(node.type, data);
|
||||||
await ctx.db.patch(nodeId, { data });
|
await ctx.db.patch(nodeId, { data: normalizedData });
|
||||||
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
|
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
72
convex/presets.ts
Normal file
72
convex/presets.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
import { requireAuth } from "./helpers";
|
||||||
|
import { adjustmentPresetNodeTypeValidator } from "./node-type-validator";
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: {
|
||||||
|
nodeType: v.optional(adjustmentPresetNodeTypeValidator),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { nodeType }) => {
|
||||||
|
const user = await requireAuth(ctx);
|
||||||
|
|
||||||
|
if (nodeType) {
|
||||||
|
return await ctx.db
|
||||||
|
.query("adjustmentPresets")
|
||||||
|
.withIndex("by_userId_nodeType", (q) =>
|
||||||
|
q.eq("userId", user.userId).eq("nodeType", nodeType),
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db
|
||||||
|
.query("adjustmentPresets")
|
||||||
|
.withIndex("by_userId", (q) => q.eq("userId", user.userId))
|
||||||
|
.order("desc")
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const save = mutation({
|
||||||
|
args: {
|
||||||
|
name: v.string(),
|
||||||
|
nodeType: adjustmentPresetNodeTypeValidator,
|
||||||
|
params: v.any(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { name, nodeType, params }) => {
|
||||||
|
const user = await requireAuth(ctx);
|
||||||
|
const normalizedName = name.trim();
|
||||||
|
if (!normalizedName) {
|
||||||
|
throw new Error("Preset name cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert("adjustmentPresets", {
|
||||||
|
userId: user.userId,
|
||||||
|
name: normalizedName,
|
||||||
|
nodeType,
|
||||||
|
params,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: {
|
||||||
|
presetId: v.id("adjustmentPresets"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { presetId }) => {
|
||||||
|
const user = await requireAuth(ctx);
|
||||||
|
const preset = await ctx.db.get(presetId);
|
||||||
|
if (!preset) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (preset.userId !== user.userId) {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(presetId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { defineSchema, defineTable } from "convex/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
adjustmentPresetNodeTypeValidator,
|
||||||
nodeTypeValidator,
|
nodeTypeValidator,
|
||||||
phase1NodeTypeValidator,
|
phase1NodeTypeValidator,
|
||||||
} from "./node-type-validator";
|
} from "./node-type-validator";
|
||||||
@@ -19,6 +20,7 @@ const phase1NodeTypes = phase1NodeTypeValidator;
|
|||||||
// jedem Phasenübergang migriert werden muss. Die UI zeigt nur die Typen
|
// jedem Phasenübergang migriert werden muss. Die UI zeigt nur die Typen
|
||||||
// der jeweiligen Phase an.
|
// der jeweiligen Phase an.
|
||||||
const nodeType = nodeTypeValidator;
|
const nodeType = nodeTypeValidator;
|
||||||
|
const adjustmentPresetNodeType = adjustmentPresetNodeTypeValidator;
|
||||||
|
|
||||||
// Node Status — direkt am Node sichtbar (UX-Strategie aus dem PRD)
|
// Node Status — direkt am Node sichtbar (UX-Strategie aus dem PRD)
|
||||||
const nodeStatus = v.union(
|
const nodeStatus = v.union(
|
||||||
@@ -175,6 +177,16 @@ export default defineSchema({
|
|||||||
"clientRequestId",
|
"clientRequestId",
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
adjustmentPresets: defineTable({
|
||||||
|
userId: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
nodeType: adjustmentPresetNodeType,
|
||||||
|
params: v.any(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_userId", ["userId"])
|
||||||
|
.index("by_userId_nodeType", ["userId", "nodeType"]),
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Credit-System
|
// Credit-System
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@@ -52,12 +52,23 @@ export const ADJUSTMENT_NODE_TYPES = [
|
|||||||
"render",
|
"render",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const ADJUSTMENT_PRESET_NODE_TYPES = [
|
||||||
|
"curves",
|
||||||
|
"color-adjust",
|
||||||
|
"light-adjust",
|
||||||
|
"detail-adjust",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type CanvasNodeType = (typeof CANVAS_NODE_TYPES)[number];
|
export type CanvasNodeType = (typeof CANVAS_NODE_TYPES)[number];
|
||||||
export type Phase1CanvasNodeType = (typeof PHASE1_CANVAS_NODE_TYPES)[number];
|
export type Phase1CanvasNodeType = (typeof PHASE1_CANVAS_NODE_TYPES)[number];
|
||||||
export type AdjustmentNodeType = (typeof ADJUSTMENT_NODE_TYPES)[number];
|
export type AdjustmentNodeType = (typeof ADJUSTMENT_NODE_TYPES)[number];
|
||||||
|
export type AdjustmentPresetNodeType = (typeof ADJUSTMENT_PRESET_NODE_TYPES)[number];
|
||||||
|
|
||||||
const CANVAS_NODE_TYPE_SET = new Set<CanvasNodeType>(CANVAS_NODE_TYPES);
|
const CANVAS_NODE_TYPE_SET = new Set<CanvasNodeType>(CANVAS_NODE_TYPES);
|
||||||
const ADJUSTMENT_NODE_TYPE_SET = new Set<AdjustmentNodeType>(ADJUSTMENT_NODE_TYPES);
|
const ADJUSTMENT_NODE_TYPE_SET = new Set<AdjustmentNodeType>(ADJUSTMENT_NODE_TYPES);
|
||||||
|
const ADJUSTMENT_PRESET_NODE_TYPE_SET = new Set<AdjustmentPresetNodeType>(
|
||||||
|
ADJUSTMENT_PRESET_NODE_TYPES,
|
||||||
|
);
|
||||||
|
|
||||||
export function isCanvasNodeType(value: string): value is CanvasNodeType {
|
export function isCanvasNodeType(value: string): value is CanvasNodeType {
|
||||||
return CANVAS_NODE_TYPE_SET.has(value as CanvasNodeType);
|
return CANVAS_NODE_TYPE_SET.has(value as CanvasNodeType);
|
||||||
@@ -66,3 +77,9 @@ export function isCanvasNodeType(value: string): value is CanvasNodeType {
|
|||||||
export function isAdjustmentNodeType(value: string): value is AdjustmentNodeType {
|
export function isAdjustmentNodeType(value: string): value is AdjustmentNodeType {
|
||||||
return ADJUSTMENT_NODE_TYPE_SET.has(value as AdjustmentNodeType);
|
return ADJUSTMENT_NODE_TYPE_SET.has(value as AdjustmentNodeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAdjustmentPresetNodeType(
|
||||||
|
value: string,
|
||||||
|
): value is AdjustmentPresetNodeType {
|
||||||
|
return ADJUSTMENT_PRESET_NODE_TYPE_SET.has(value as AdjustmentPresetNodeType);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user