mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-14 03:30:37 +00:00
Compare commits
2 Commits
fix/codera
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5679af17b1 | ||
|
|
f3af3c90a7 |
@@ -46,6 +46,9 @@ const config: KnipConfig = {
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Pending integration - accessible color picker components
|
||||
'src/components/ui/color-picker/ColorPickerSaturationValue.vue',
|
||||
'src/components/ui/color-picker/ColorPickerSlider.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
],
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { hue } = defineProps<{
|
||||
hue: number
|
||||
}>()
|
||||
|
||||
const saturation = defineModel<number>('saturation', { required: true })
|
||||
const value = defineModel<number>('value', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
|
||||
|
||||
const handleStyle = computed(() => ({
|
||||
left: `${saturation.value}%`,
|
||||
top: `${100 - value.value}%`
|
||||
}))
|
||||
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, v))
|
||||
}
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||
saturation.value = Math.round(x * 100)
|
||||
value.value = Math.round((1 - y) * 100)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
saturation.value = clamp(saturation.value - step, 0, 100)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
saturation.value = clamp(saturation.value + step, 0, 100)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
value.value = clamp(value.value + step, 0, 100)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
value.value = clamp(value.value - step, 0, 100)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
:aria-label="t('colorPicker.saturationValue')"
|
||||
:aria-valuetext="`${t('colorPicker.saturation')}: ${saturation}%, ${t('colorPicker.brightness')}: ${value}%`"
|
||||
class="relative aspect-square w-full cursor-crosshair rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-highlight"
|
||||
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="handleStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
115
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
115
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
|
||||
const {
|
||||
type,
|
||||
hue = 0,
|
||||
saturation = 100,
|
||||
brightness = 100
|
||||
} = defineProps<{
|
||||
type: 'hue' | 'alpha'
|
||||
hue?: number
|
||||
saturation?: number
|
||||
brightness?: number
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const max = computed(() => (type === 'hue' ? 360 : 100))
|
||||
|
||||
const fraction = computed(() => modelValue.value / max.value)
|
||||
|
||||
const trackBackground = computed(() => {
|
||||
if (type === 'hue') {
|
||||
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
|
||||
}
|
||||
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
|
||||
const hex = rgbToHex(rgb)
|
||||
return `linear-gradient(to right, transparent, ${hex})`
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
if (type === 'alpha') {
|
||||
return {
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '8px 8px',
|
||||
touchAction: 'none'
|
||||
}
|
||||
}
|
||||
return {
|
||||
background: trackBackground.value,
|
||||
touchAction: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
type === 'hue' ? t('colorPicker.hue') : t('colorPicker.alpha')
|
||||
)
|
||||
|
||||
function clamp(v: number, min: number, maxVal: number) {
|
||||
return Math.max(min, Math.min(maxVal, v))
|
||||
}
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
modelValue.value = Math.round(x * max.value)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
modelValue.value = clamp(modelValue.value - step, 0, max.value)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
modelValue.value = clamp(modelValue.value + step, 0, max.value)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-valuenow="modelValue"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="max"
|
||||
class="relative flex h-4 cursor-pointer items-center rounded-full p-px outline-none focus-visible:ring-2 focus-visible:ring-highlight"
|
||||
:style="containerStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div
|
||||
v-if="type === 'alpha'"
|
||||
class="absolute inset-0 rounded-full"
|
||||
:style="{ background: trackBackground }"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="{ left: `${fraction * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -483,6 +483,13 @@
|
||||
"black": "Black",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"colorPicker": {
|
||||
"saturationValue": "Saturation and brightness",
|
||||
"saturation": "Saturation",
|
||||
"brightness": "Brightness",
|
||||
"hue": "Hue",
|
||||
"alpha": "Alpha"
|
||||
},
|
||||
"contextMenu": {
|
||||
"Inputs": "Inputs",
|
||||
"Outputs": "Outputs",
|
||||
|
||||
@@ -379,13 +379,6 @@ export const useWorkflowService = () => {
|
||||
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
||||
domWidgetStore.clear()
|
||||
}
|
||||
|
||||
// Deactivate the current workflow before the graph is reconfigured.
|
||||
// This ensures there is never a window where activeWorkflow references
|
||||
// the OLD workflow while rootGraph already contains NEW data — any
|
||||
// checkState or data-sync path that reads activeWorkflow will see null
|
||||
// and naturally skip, without needing a guard flag.
|
||||
workflowStore.activeWorkflow = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
|
||||
import { st, t } from '@/i18n'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraph,
|
||||
@@ -1306,136 +1307,143 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
try {
|
||||
// @ts-expect-error Discrepancies between zod and litegraph - in progress
|
||||
this.rootGraph.configure(graphData)
|
||||
try {
|
||||
// @ts-expect-error Discrepancies between zod and litegraph - in progress
|
||||
this.rootGraph.configure(graphData)
|
||||
|
||||
// Save original renderer version before scaling (it gets modified during scaling)
|
||||
const originalMainGraphRenderer =
|
||||
this.rootGraph.extra.workflowRendererVersion
|
||||
// Save original renderer version before scaling (it gets modified during scaling)
|
||||
const originalMainGraphRenderer =
|
||||
this.rootGraph.extra.workflowRendererVersion
|
||||
|
||||
// Scale main graph
|
||||
ensureCorrectLayoutScale(originalMainGraphRenderer)
|
||||
// Scale main graph
|
||||
ensureCorrectLayoutScale(originalMainGraphRenderer)
|
||||
|
||||
// Scale all subgraphs that were loaded with the workflow
|
||||
// Use original main graph renderer as fallback (not the modified one)
|
||||
for (const subgraph of this.rootGraph.subgraphs.values()) {
|
||||
ensureCorrectLayoutScale(
|
||||
subgraph.extra.workflowRendererVersion || originalMainGraphRenderer,
|
||||
subgraph
|
||||
)
|
||||
// Scale all subgraphs that were loaded with the workflow
|
||||
// Use original main graph renderer as fallback (not the modified one)
|
||||
for (const subgraph of this.rootGraph.subgraphs.values()) {
|
||||
ensureCorrectLayoutScale(
|
||||
subgraph.extra.workflowRendererVersion || originalMainGraphRenderer,
|
||||
subgraph
|
||||
)
|
||||
}
|
||||
|
||||
if (canvasVisible) fitView()
|
||||
} catch (error) {
|
||||
useDialogService().showErrorDialog(error, {
|
||||
title: t('errorDialog.loadWorkflowTitle'),
|
||||
reportType: 'loadWorkflowError'
|
||||
})
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (canvasVisible) fitView()
|
||||
} catch (error) {
|
||||
useDialogService().showErrorDialog(error, {
|
||||
title: t('errorDialog.loadWorkflowTitle'),
|
||||
reportType: 'loadWorkflowError'
|
||||
})
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
forEachNode(this.rootGraph, (node) => {
|
||||
const size = node.computeSize()
|
||||
size[0] = Math.max(node.size[0], size[0])
|
||||
size[1] = Math.max(node.size[1], size[1])
|
||||
node.setSize(size)
|
||||
if (node.widgets) {
|
||||
// If you break something in the backend and want to patch workflows in the frontend
|
||||
// This is the place to do this
|
||||
for (let widget of node.widgets) {
|
||||
if (node.type == 'KSampler' || node.type == 'KSamplerAdvanced') {
|
||||
if (widget.name == 'sampler_name') {
|
||||
if (
|
||||
typeof widget.value === 'string' &&
|
||||
widget.value.startsWith('sample_')
|
||||
) {
|
||||
widget.value = widget.value.slice(7)
|
||||
forEachNode(this.rootGraph, (node) => {
|
||||
const size = node.computeSize()
|
||||
size[0] = Math.max(node.size[0], size[0])
|
||||
size[1] = Math.max(node.size[1], size[1])
|
||||
node.setSize(size)
|
||||
if (node.widgets) {
|
||||
// If you break something in the backend and want to patch workflows in the frontend
|
||||
// This is the place to do this
|
||||
for (let widget of node.widgets) {
|
||||
if (node.type == 'KSampler' || node.type == 'KSamplerAdvanced') {
|
||||
if (widget.name == 'sampler_name') {
|
||||
if (
|
||||
typeof widget.value === 'string' &&
|
||||
widget.value.startsWith('sample_')
|
||||
) {
|
||||
widget.value = widget.value.slice(7)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
node.type == 'KSampler' ||
|
||||
node.type == 'KSamplerAdvanced' ||
|
||||
node.type == 'PrimitiveNode'
|
||||
) {
|
||||
if (widget.name == 'control_after_generate') {
|
||||
if (widget.value === true) {
|
||||
widget.value = 'randomize'
|
||||
} else if (widget.value === false) {
|
||||
widget.value = 'fixed'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.type == 'combo') {
|
||||
const values = widget.options.values as
|
||||
| (string | number | boolean)[]
|
||||
| undefined
|
||||
if (
|
||||
values &&
|
||||
values.length > 0 &&
|
||||
(widget.value == null ||
|
||||
(reset_invalid_values &&
|
||||
!values.includes(widget.value as string | number | boolean)))
|
||||
node.type == 'KSampler' ||
|
||||
node.type == 'KSamplerAdvanced' ||
|
||||
node.type == 'PrimitiveNode'
|
||||
) {
|
||||
widget.value = values[0]
|
||||
if (widget.name == 'control_after_generate') {
|
||||
if (widget.value === true) {
|
||||
widget.value = 'randomize'
|
||||
} else if (widget.value === false) {
|
||||
widget.value = 'fixed'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.type == 'combo') {
|
||||
const values = widget.options.values as
|
||||
| (string | number | boolean)[]
|
||||
| undefined
|
||||
if (
|
||||
values &&
|
||||
values.length > 0 &&
|
||||
(widget.value == null ||
|
||||
(reset_invalid_values &&
|
||||
!values.includes(
|
||||
widget.value as string | number | boolean
|
||||
)))
|
||||
) {
|
||||
widget.value = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useExtensionService().invokeExtensions('loadedGraphNode', node)
|
||||
})
|
||||
|
||||
await useExtensionService().invokeExtensionsAsync(
|
||||
'afterConfigureGraph',
|
||||
missingNodeTypes
|
||||
)
|
||||
|
||||
const telemetryPayload = {
|
||||
missing_node_count: missingNodeTypes.length,
|
||||
missing_node_types: missingNodeTypes.map((node) =>
|
||||
typeof node === 'string' ? node : node.type
|
||||
),
|
||||
open_source: openSource ?? 'unknown'
|
||||
}
|
||||
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
|
||||
useTelemetry()?.trackWorkflowImported(telemetryPayload)
|
||||
await useWorkflowService().afterLoadNewGraph(
|
||||
workflow,
|
||||
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
|
||||
)
|
||||
|
||||
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view
|
||||
// This fixes switching from app mode to a new graph mode workflow (e.g. load template)
|
||||
if (!canvasVisible && (!workflow || typeof workflow === 'string')) {
|
||||
this.canvas.resize()
|
||||
requestAnimationFrame(() => fitView())
|
||||
}
|
||||
|
||||
useExtensionService().invokeExtensions('loadedGraphNode', node)
|
||||
})
|
||||
|
||||
await useExtensionService().invokeExtensionsAsync(
|
||||
'afterConfigureGraph',
|
||||
missingNodeTypes
|
||||
)
|
||||
|
||||
const telemetryPayload = {
|
||||
missing_node_count: missingNodeTypes.length,
|
||||
missing_node_types: missingNodeTypes.map((node) =>
|
||||
typeof node === 'string' ? node : node.type
|
||||
),
|
||||
open_source: openSource ?? 'unknown'
|
||||
}
|
||||
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
|
||||
useTelemetry()?.trackWorkflowImported(telemetryPayload)
|
||||
await useWorkflowService().afterLoadNewGraph(
|
||||
workflow,
|
||||
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
|
||||
)
|
||||
|
||||
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view
|
||||
// This fixes switching from app mode to a new graph mode workflow (e.g. load template)
|
||||
if (!canvasVisible && (!workflow || typeof workflow === 'string')) {
|
||||
this.canvas.resize()
|
||||
requestAnimationFrame(() => fitView())
|
||||
}
|
||||
|
||||
// Store pending warnings on the workflow for deferred display
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
if (activeWf) {
|
||||
const warnings: PendingWarnings = {}
|
||||
if (missingNodeTypes.length && showMissingNodesDialog) {
|
||||
warnings.missingNodeTypes = missingNodeTypes
|
||||
// Store pending warnings on the workflow for deferred display
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
if (activeWf) {
|
||||
const warnings: PendingWarnings = {}
|
||||
if (missingNodeTypes.length && showMissingNodesDialog) {
|
||||
warnings.missingNodeTypes = missingNodeTypes
|
||||
}
|
||||
if (missingModels.length && showMissingModelsDialog) {
|
||||
const paths = await api.getFolderPaths()
|
||||
warnings.missingModels = { missingModels: missingModels, paths }
|
||||
}
|
||||
if (warnings.missingNodeTypes || warnings.missingModels) {
|
||||
activeWf.pendingWarnings = warnings
|
||||
}
|
||||
}
|
||||
if (missingModels.length && showMissingModelsDialog) {
|
||||
const paths = await api.getFolderPaths()
|
||||
warnings.missingModels = { missingModels: missingModels, paths }
|
||||
}
|
||||
if (warnings.missingNodeTypes || warnings.missingModels) {
|
||||
activeWf.pendingWarnings = warnings
|
||||
}
|
||||
}
|
||||
|
||||
if (!deferWarnings) {
|
||||
useWorkflowService().showPendingWarnings()
|
||||
}
|
||||
if (!deferWarnings) {
|
||||
useWorkflowService().showPendingWarnings()
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.canvas.setDirty(true, true)
|
||||
})
|
||||
requestAnimationFrame(() => {
|
||||
this.canvas.setDirty(true, true)
|
||||
})
|
||||
} finally {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
}
|
||||
}
|
||||
|
||||
async graphToPrompt(graph = this.rootGraph) {
|
||||
|
||||
@@ -28,6 +28,14 @@ logger.setLevel('info')
|
||||
|
||||
export class ChangeTracker {
|
||||
static MAX_HISTORY = 50
|
||||
/**
|
||||
* Guard flag to prevent checkState from running during loadGraphData.
|
||||
* Between rootGraph.configure() and afterLoadNewGraph(), the rootGraph
|
||||
* contains the NEW workflow's data while activeWorkflow still points to
|
||||
* the OLD workflow. Any checkState call in that window would serialize
|
||||
* the wrong graph into the old workflow's activeState, corrupting it.
|
||||
*/
|
||||
static isLoadingGraph = false
|
||||
/**
|
||||
* The active state of the workflow.
|
||||
*/
|
||||
@@ -131,7 +139,7 @@ export class ChangeTracker {
|
||||
}
|
||||
|
||||
checkState() {
|
||||
if (!app.graph || this.changeCount) return
|
||||
if (!app.graph || this.changeCount || ChangeTracker.isLoadingGraph) return
|
||||
const currentState = clone(app.rootGraph.serialize()) as ComfyWorkflowJSON
|
||||
if (!this.activeState) {
|
||||
this.activeState = currentState
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
export function nodeTypeValidForApp(type: string) {
|
||||
@@ -81,7 +82,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
? { inputs: selectedInputs, outputs: selectedOutputs }
|
||||
: null,
|
||||
(data) => {
|
||||
if (!data || !workflowStore.activeWorkflow) return
|
||||
if (!data || ChangeTracker.isLoadingGraph) return
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return
|
||||
const extra = (graph.extra ??= {})
|
||||
|
||||
Reference in New Issue
Block a user