Add recipe schema validation to UI (#4602)
This commit is contained in:
@@ -9,6 +9,7 @@ import { toastSuccess, toastError } from '../../toasts';
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField';
|
||||
import { generateRecipeNameFromTitle } from './shared/recipeNameUtils';
|
||||
import { validateJsonSchema, getValidationErrorMessages } from '../../recipe/validation';
|
||||
|
||||
interface CreateRecipeFormProps {
|
||||
isOpen: boolean;
|
||||
@@ -23,6 +24,7 @@ const createRecipeSchema = z.object({
|
||||
instructions: z.string().min(20, 'Instructions must be at least 20 characters'),
|
||||
prompt: z.string(),
|
||||
activities: z.string(),
|
||||
jsonSchema: z.string(),
|
||||
recipeName: recipeNameSchema,
|
||||
global: z.boolean(),
|
||||
});
|
||||
@@ -40,6 +42,7 @@ export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateR
|
||||
instructions: '',
|
||||
prompt: '',
|
||||
activities: '',
|
||||
jsonSchema: '',
|
||||
recipeName: '',
|
||||
global: true,
|
||||
},
|
||||
@@ -55,6 +58,24 @@ export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateR
|
||||
.map((activity) => activity.trim())
|
||||
.filter((activity) => activity.length > 0);
|
||||
|
||||
// Parse and validate JSON schema if provided
|
||||
let jsonSchemaObj = undefined;
|
||||
if (value.jsonSchema && value.jsonSchema.trim()) {
|
||||
try {
|
||||
jsonSchemaObj = JSON.parse(value.jsonSchema.trim());
|
||||
// Validate the JSON schema syntax
|
||||
const validationResult = validateJsonSchema(jsonSchemaObj);
|
||||
if (!validationResult.success) {
|
||||
const errorMessages = getValidationErrorMessages(validationResult.errors);
|
||||
throw new Error(`Invalid JSON schema: ${errorMessages.join(', ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`JSON Schema parsing error: ${error instanceof Error ? error.message : 'Invalid JSON'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the recipe object
|
||||
const recipe: Recipe = {
|
||||
title: value.title.trim(),
|
||||
@@ -62,6 +83,7 @@ export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateR
|
||||
instructions: value.instructions.trim(),
|
||||
prompt: value.prompt.trim() || undefined,
|
||||
activities: activities.length > 0 ? activities : undefined,
|
||||
response: jsonSchemaObj ? { json_schema: jsonSchemaObj } : undefined,
|
||||
};
|
||||
|
||||
await saveRecipe(recipe, {
|
||||
@@ -76,6 +98,7 @@ export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateR
|
||||
instructions: '',
|
||||
prompt: '',
|
||||
activities: '',
|
||||
jsonSchema: '',
|
||||
recipeName: '',
|
||||
global: true,
|
||||
});
|
||||
@@ -144,6 +167,7 @@ Parameters you can use:
|
||||
instructions: '',
|
||||
prompt: '',
|
||||
activities: '',
|
||||
jsonSchema: '',
|
||||
recipeName: '',
|
||||
global: true,
|
||||
});
|
||||
@@ -317,6 +341,49 @@ Parameters you can use:
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="jsonSchema">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="create-json-schema"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Response JSON Schema (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="create-json-schema"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm ${
|
||||
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
|
||||
}`}
|
||||
placeholder={`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": {
|
||||
"type": "string",
|
||||
"description": "The main result"
|
||||
}
|
||||
},
|
||||
"required": ["result"]
|
||||
}`}
|
||||
rows={6}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Define the expected structure of the AI's response using JSON Schema format
|
||||
</p>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</createRecipeForm.Field>
|
||||
|
||||
<createRecipeForm.Field name="recipeName">
|
||||
{(field) => (
|
||||
<RecipeNameField
|
||||
|
||||
@@ -11,6 +11,11 @@ import { toastSuccess, toastError } from '../../toasts';
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField';
|
||||
import { generateRecipeNameFromTitle } from './shared/recipeNameUtils';
|
||||
import {
|
||||
validateRecipe,
|
||||
getValidationErrorMessages,
|
||||
getRecipeJsonSchema,
|
||||
} from '../../recipe/validation';
|
||||
|
||||
interface ImportRecipeFormProps {
|
||||
isOpen: boolean;
|
||||
@@ -44,6 +49,7 @@ const importRecipeSchema = z
|
||||
|
||||
export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportRecipeFormProps) {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [showSchemaModal, setShowSchemaModal] = useState(false);
|
||||
|
||||
// Handle Esc key for modal
|
||||
useEscapeKey(isOpen, onClose);
|
||||
@@ -119,6 +125,12 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
||||
recipe = await parseYamlFile(fileContent);
|
||||
}
|
||||
|
||||
const validationResult = validateRecipe(recipe);
|
||||
if (!validationResult.success) {
|
||||
const errorMessages = getValidationErrorMessages(validationResult.errors);
|
||||
throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`);
|
||||
}
|
||||
|
||||
await saveRecipe(recipe, {
|
||||
name: value.recipeName.trim(),
|
||||
global: value.global,
|
||||
@@ -236,200 +248,247 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
|
||||
<h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
|
||||
<>
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
|
||||
<h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
importRecipeForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<importRecipeForm.Subscribe selector={(state) => state.values}>
|
||||
{(values) => (
|
||||
<>
|
||||
<importRecipeForm.Field name="deeplink">
|
||||
{(field) => {
|
||||
const isDisabled = values.yamlFile !== null;
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
importRecipeForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<importRecipeForm.Subscribe selector={(state) => state.values}>
|
||||
{(values) => (
|
||||
<>
|
||||
<importRecipeForm.Field name="deeplink">
|
||||
{(field) => {
|
||||
const isDisabled = values.yamlFile !== null;
|
||||
|
||||
return (
|
||||
<div className={isDisabled ? 'opacity-50' : ''}>
|
||||
<label
|
||||
htmlFor="import-deeplink"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Recipe Deeplink
|
||||
</label>
|
||||
<textarea
|
||||
id="import-deeplink"
|
||||
value={field.state.value}
|
||||
onChange={(e) => handleDeeplinkChange(e.target.value, field)}
|
||||
onBlur={field.handleBlur}
|
||||
disabled={isDisabled}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none ${
|
||||
field.state.meta.errors.length > 0
|
||||
? 'border-red-500'
|
||||
: 'border-border-subtle'
|
||||
} ${isDisabled ? 'cursor-not-allowed bg-gray-40 text-gray-300' : ''}`}
|
||||
placeholder="Paste your goose://recipe?config=... deeplink here"
|
||||
rows={3}
|
||||
autoFocus={!isDisabled}
|
||||
/>
|
||||
<p
|
||||
className={`text-xs mt-1 ${isDisabled ? 'text-gray-300' : 'text-text-muted'}`}
|
||||
>
|
||||
Paste a recipe deeplink starting with "goose://recipe?config="
|
||||
</p>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message ||
|
||||
String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</importRecipeForm.Field>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border-subtle" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-3 bg-background-default text-text-muted font-medium">
|
||||
OR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<importRecipeForm.Field name="yamlFile">
|
||||
{(field) => {
|
||||
const hasDeeplink = values.deeplink?.trim();
|
||||
const isDisabled = !!hasDeeplink;
|
||||
|
||||
return (
|
||||
<div className={isDisabled ? 'opacity-50' : ''}>
|
||||
<label
|
||||
htmlFor="import-yaml-file"
|
||||
className="block text-sm font-medium text-text-standard mb-3"
|
||||
>
|
||||
Recipe YAML File
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="import-yaml-file"
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => {
|
||||
handleYamlFileChange(e.target.files?.[0]);
|
||||
}}
|
||||
return (
|
||||
<div className={isDisabled ? 'opacity-50' : ''}>
|
||||
<label
|
||||
htmlFor="import-deeplink"
|
||||
className="block text-sm font-medium text-text-standard mb-2"
|
||||
>
|
||||
Recipe Deeplink
|
||||
</label>
|
||||
<textarea
|
||||
id="import-deeplink"
|
||||
value={field.state.value}
|
||||
onChange={(e) => handleDeeplinkChange(e.target.value, field)}
|
||||
onBlur={field.handleBlur}
|
||||
className={`${field.state.meta.errors.length > 0 ? 'border-red-500' : ''} ${
|
||||
isDisabled ? 'cursor-not-allowed' : ''
|
||||
}`}
|
||||
disabled={isDisabled}
|
||||
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none ${
|
||||
field.state.meta.errors.length > 0
|
||||
? 'border-red-500'
|
||||
: 'border-border-subtle'
|
||||
} ${isDisabled ? 'cursor-not-allowed bg-gray-40 text-gray-300' : ''}`}
|
||||
placeholder="Paste your goose://recipe?config=... deeplink here"
|
||||
rows={3}
|
||||
autoFocus={!isDisabled}
|
||||
/>
|
||||
</div>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message ||
|
||||
String(field.state.meta.errors[0])}
|
||||
<p
|
||||
className={`text-xs mt-1 ${isDisabled ? 'text-gray-300' : 'text-text-muted'}`}
|
||||
>
|
||||
Paste a recipe deeplink starting with "goose://recipe?config="
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</importRecipeForm.Field>
|
||||
</>
|
||||
)}
|
||||
</importRecipeForm.Subscribe>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message ||
|
||||
String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</importRecipeForm.Field>
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
Ensure you review contents of YAML files before adding them to your goose interface.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border-subtle" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-3 bg-background-default text-text-muted font-medium">
|
||||
OR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<importRecipeForm.Field name="recipeName">
|
||||
{(field) => {
|
||||
// Store reference to the field for programmatic updates
|
||||
recipeNameFieldRef = field;
|
||||
<importRecipeForm.Field name="yamlFile">
|
||||
{(field) => {
|
||||
const hasDeeplink = values.deeplink?.trim();
|
||||
const isDisabled = !!hasDeeplink;
|
||||
|
||||
return (
|
||||
<RecipeNameField
|
||||
id="import-recipe-name"
|
||||
value={field.state.value}
|
||||
onChange={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
errors={field.state.meta.errors.map((error) =>
|
||||
typeof error === 'string' ? error : error?.message || String(error)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</importRecipeForm.Field>
|
||||
return (
|
||||
<div className={isDisabled ? 'opacity-50' : ''}>
|
||||
<label
|
||||
htmlFor="import-yaml-file"
|
||||
className="block text-sm font-medium text-text-standard mb-3"
|
||||
>
|
||||
Recipe YAML File
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="import-yaml-file"
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => {
|
||||
handleYamlFileChange(e.target.files?.[0]);
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
className={`${field.state.meta.errors.length > 0 ? 'border-red-500' : ''} ${
|
||||
isDisabled ? 'cursor-not-allowed' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={`text-xs mt-1 ${isDisabled ? 'text-gray-300' : 'text-text-muted'}`}
|
||||
>
|
||||
Upload a YAML file containing the recipe structure
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSchemaModal(true)}
|
||||
className="text-xs text-blue-500 hover:text-blue-700 underline"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
example
|
||||
</button>
|
||||
</div>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{typeof field.state.meta.errors[0] === 'string'
|
||||
? field.state.meta.errors[0]
|
||||
: field.state.meta.errors[0]?.message ||
|
||||
String(field.state.meta.errors[0])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</importRecipeForm.Field>
|
||||
</>
|
||||
)}
|
||||
</importRecipeForm.Subscribe>
|
||||
|
||||
<importRecipeForm.Field name="global">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||
Save Location
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="import-save-location"
|
||||
checked={field.state.value === true}
|
||||
onChange={() => field.handleChange(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="import-save-location"
|
||||
checked={field.state.value === false}
|
||||
onChange={() => field.handleChange(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
<p className="text-xs text-text-muted">
|
||||
Ensure you review contents of YAML files before adding them to your goose interface.
|
||||
</p>
|
||||
|
||||
<importRecipeForm.Field name="recipeName">
|
||||
{(field) => {
|
||||
// Store reference to the field for programmatic updates
|
||||
recipeNameFieldRef = field;
|
||||
|
||||
return (
|
||||
<RecipeNameField
|
||||
id="import-recipe-name"
|
||||
value={field.state.value}
|
||||
onChange={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
errors={field.state.meta.errors.map((error) =>
|
||||
typeof error === 'string' ? error : error?.message || String(error)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</importRecipeForm.Field>
|
||||
|
||||
<importRecipeForm.Field name="global">
|
||||
{(field) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||
Save Location
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="import-save-location"
|
||||
checked={field.state.value === true}
|
||||
onChange={() => field.handleChange(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="import-save-location"
|
||||
checked={field.state.value === false}
|
||||
onChange={() => field.handleChange(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-text-standard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</importRecipeForm.Field>
|
||||
</div>
|
||||
)}
|
||||
</importRecipeForm.Field>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<Button type="button" onClick={handleClose} variant="ghost" disabled={importing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<importRecipeForm.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
|
||||
{([canSubmit, isSubmitting]) => (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit || importing || isSubmitting}
|
||||
variant="default"
|
||||
>
|
||||
{importing || isSubmitting ? 'Importing...' : 'Import Recipe'}
|
||||
</Button>
|
||||
)}
|
||||
</importRecipeForm.Subscribe>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<Button type="button" onClick={handleClose} variant="ghost" disabled={importing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<importRecipeForm.Subscribe
|
||||
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
||||
>
|
||||
{([canSubmit, isSubmitting]) => (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit || importing || isSubmitting}
|
||||
variant="default"
|
||||
>
|
||||
{importing || isSubmitting ? 'Importing...' : 'Import Recipe'}
|
||||
</Button>
|
||||
)}
|
||||
</importRecipeForm.Subscribe>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema Modal */}
|
||||
{showSchemaModal && (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[800px] max-w-[90vw] max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-text-standard">Recipe Schema</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSchemaModal(false)}
|
||||
className="text-text-muted hover:text-text-standard"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<p className="font-medium mb-3 text-text-standard">Expected Recipe Structure:</p>
|
||||
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto whitespace-pre font-mono">
|
||||
{JSON.stringify(getRecipeJsonSchema(), null, 2)}
|
||||
</pre>
|
||||
<p className="mt-4 text-blue-700 text-sm">
|
||||
Your YAML file should follow this structure. Required fields are: title,
|
||||
description, and either instructions or prompt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user