mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
feat: replace PrimeVue ColorPicker with custom component (#9647)
## Summary - Replace PrimeVue `ColorPicker` with a custom component built on Reka UI Popover - New `ColorPicker` supports HSV saturation-value picking, hue/alpha sliders, hex/rgba display toggle - Simplify `WidgetColorPicker` by removing PrimeVue-specific normalization logic - Add Storybook stories for both `ColorPicker` and `WidgetColorPicker` ## Test plan - [x] Unit tests pass (9 widget tests, 47 colorUtil tests) - [x] Typecheck passes - [x] Lint passes - [ ] Verify color picker visually in Storybook - [ ] Test color picking in node widgets with hex/rgb/hsb formats ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9647-feat-replace-PrimeVue-ColorPicker-with-custom-component-31e6d73d36508114bc54d958ff8d0448) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { ColorFormat } from '@/utils/colorUtil'
|
||||
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import WidgetColorPicker from './WidgetColorPicker.vue'
|
||||
|
||||
type WidgetOptions = IWidgetOptions & { format?: ColorFormat }
|
||||
|
||||
interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetColorPicker> {
|
||||
format: ColorFormat
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Widgets/WidgetColorPicker',
|
||||
component: WidgetColorPicker,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
format: {
|
||||
control: 'select',
|
||||
options: ['hex', 'rgb', 'hsb']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
format: 'hex'
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="grid grid-cols-[auto_1fr] gap-1 w-80"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const { format } = toRefs(args)
|
||||
const value = ref('#E06CBD')
|
||||
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
|
||||
name: 'color',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: { format: format.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const RGBFormat: Story = {
|
||||
args: { format: 'rgb' },
|
||||
render: (args) => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const { format } = toRefs(args)
|
||||
const value = ref('#3498DB')
|
||||
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
|
||||
name: 'color',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: { format: format.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const HSBFormat: Story = {
|
||||
args: { format: 'hsb' },
|
||||
render: (args) => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const { format } = toRefs(args)
|
||||
const value = ref('#2ECC71')
|
||||
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
|
||||
name: 'color',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: { format: format.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const CustomColor: Story = {
|
||||
render: (args) => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const { format } = toRefs(args)
|
||||
const value = ref('#FF5733')
|
||||
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
|
||||
name: 'accent_color',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: { format: format.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const value = ref('#9B59B6')
|
||||
const widget: SimplifiedWidget<string, WidgetOptions> = {
|
||||
name: 'background',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
label: 'Background Color',
|
||||
options: { format: 'hex' }
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import type { ColorPickerProps } from 'primevue/colorpicker'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
|
||||
import WidgetColorPicker from './WidgetColorPicker.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
@@ -13,7 +12,7 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
describe('WidgetColorPicker Value Binding', () => {
|
||||
const createColorWidget = (
|
||||
value: string = '#000000',
|
||||
options: Partial<ColorPickerProps> = {},
|
||||
options: Record<string, unknown> = {},
|
||||
callback?: (value: string) => void
|
||||
) =>
|
||||
createMockWidget<string>({
|
||||
@@ -26,12 +25,10 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false
|
||||
modelValue: string
|
||||
) => {
|
||||
return mount(WidgetColorPicker, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: {
|
||||
ColorPicker,
|
||||
WidgetLayoutField
|
||||
@@ -39,93 +36,35 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
modelValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setColorPickerValue = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: unknown
|
||||
) => {
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
await colorPicker.setValue(value)
|
||||
return wrapper.emitted('update:modelValue')
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when color changes', async () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#00ff00')
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
await colorPicker.vm.$emit('update:modelValue', '#00ff00')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('handles different color formats', async () => {
|
||||
const widget = createColorWidget('#ffffff')
|
||||
const wrapper = mountComponent(widget, '#ffffff')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#123abc')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#123abc')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createColorWidget('#000000', {}, undefined)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
await colorPicker.vm.$emit('update:modelValue', '#ff00ff')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff00ff')
|
||||
})
|
||||
|
||||
it('normalizes bare hex without # to #hex on emit', async () => {
|
||||
const widget = createColorWidget('ff0000')
|
||||
const wrapper = mountComponent(widget, 'ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '00ff00')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes rgb() strings to #hex on emit', async (context) => {
|
||||
context.skip('needs diagnosis')
|
||||
const widget = createColorWidget('#000000')
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff0000')
|
||||
})
|
||||
|
||||
it('normalizes hsb() strings to #hex on emit', async () => {
|
||||
const widget = createColorWidget('#000000', { format: 'hsb' })
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes HSB object values to #hex on emit', async () => {
|
||||
const widget = createColorWidget('#000000', { format: 'hsb' })
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, {
|
||||
h: 240,
|
||||
s: 100,
|
||||
b: 100
|
||||
})
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#0000ff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
@@ -133,110 +72,37 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes display to a single leading #', () => {
|
||||
// Case 1: model value already includes '#'
|
||||
let widget = createColorWidget('#ff0000')
|
||||
let wrapper = mountComponent(widget, '#ff0000')
|
||||
let colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe('#ff0000')
|
||||
|
||||
// Case 2: model value missing '#'
|
||||
widget = createColorWidget('ff0000')
|
||||
wrapper = mountComponent(widget, 'ff0000')
|
||||
colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('renders layout field wrapper', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
const layoutField = wrapper.findComponent({
|
||||
name: 'WidgetLayoutField'
|
||||
})
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays current color value as text', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('updates color text when value changes', async () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
await setColorPickerValue(wrapper, '#00ff00')
|
||||
|
||||
// Need to check the local state update
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
// Be specific about the displayed value including the leading '#'
|
||||
expect.soft(colorText.text()).toBe('#00ff00')
|
||||
})
|
||||
|
||||
it('uses default color when no value provided', () => {
|
||||
const widget = createColorWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
// Should use the default value from the composable
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Color Formats', () => {
|
||||
it('handles valid hex colors', async () => {
|
||||
const validHexColors = [
|
||||
'#000000',
|
||||
'#ffffff',
|
||||
'#ff0000',
|
||||
'#00ff00',
|
||||
'#0000ff',
|
||||
'#123abc'
|
||||
]
|
||||
|
||||
for (const color of validHexColors) {
|
||||
const widget = createColorWidget(color)
|
||||
const wrapper = mountComponent(widget, color)
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe(color)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles short hex colors', () => {
|
||||
const widget = createColorWidget('#fff')
|
||||
const wrapper = mountComponent(widget, '#fff')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#fff')
|
||||
})
|
||||
|
||||
it('passes widget options to color picker', () => {
|
||||
const colorOptions = {
|
||||
format: 'hex' as const,
|
||||
inline: true
|
||||
}
|
||||
const widget = createColorWidget('#ff0000', colorOptions)
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.props('format')).toBe('hex')
|
||||
expect(colorPicker.props('inline')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget Layout Integration', () => {
|
||||
it('passes widget to layout field', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
const layoutField = wrapper.findComponent({
|
||||
name: 'WidgetLayoutField'
|
||||
})
|
||||
expect(layoutField.props('widget')).toEqual(widget)
|
||||
})
|
||||
|
||||
@@ -244,16 +110,13 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
// Should have layout field containing label with color picker and text
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
const label = wrapper.find('label')
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorText = wrapper.find('span')
|
||||
const layoutField = wrapper.findComponent({
|
||||
name: 'WidgetLayoutField'
|
||||
})
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
expect(colorText.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -262,27 +125,15 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
const widget = createColorWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles invalid color formats gracefully', async () => {
|
||||
const widget = createColorWidget('invalid-color')
|
||||
const wrapper = mountComponent(widget, 'invalid-color')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#000000')
|
||||
})
|
||||
|
||||
it('handles widget with no options', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,90 +1,42 @@
|
||||
<!-- Needs custom color picker for alpha support -->
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<label
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex w-full items-center gap-2 px-4 py-2')
|
||||
"
|
||||
>
|
||||
<ColorPicker
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
class="h-4 w-8 overflow-hidden rounded-full! border-none"
|
||||
:aria-label="widget.name"
|
||||
:pt="{
|
||||
preview: '!w-full !h-full !border-none'
|
||||
}"
|
||||
@update:model-value="onPickerUpdate"
|
||||
/>
|
||||
<span
|
||||
class="min-w-[4ch] truncate text-xs"
|
||||
data-testid="widget-color-text"
|
||||
>{{ toHexFromFormat(localValue, format) }}</span
|
||||
>
|
||||
</label>
|
||||
<ColorPicker v-model="localValue" @update:model-value="onUpdate" />
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { isColorFormat, toHexFromFormat } from '@/utils/colorUtil'
|
||||
import type { ColorFormat, HSB } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
import type { ColorFormat } from '@/utils/colorUtil'
|
||||
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
type WidgetOptions = IWidgetOptions & { format?: ColorFormat }
|
||||
|
||||
const props = defineProps<{
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<string, WidgetOptions>
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const format = computed<ColorFormat>(() => {
|
||||
const optionFormat = props.widget.options?.format
|
||||
return isColorFormat(optionFormat) ? optionFormat : 'hex'
|
||||
const format = isColorFormat(widget.options?.format)
|
||||
? widget.options.format
|
||||
: 'hex'
|
||||
|
||||
const localValue = ref(toHexFromFormat(modelValue.value || '#000000', format))
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
localValue.value = toHexFromFormat(newVal || '#000000', format)
|
||||
})
|
||||
|
||||
type PickerValue = string | HSB
|
||||
const localValue = ref<PickerValue>(
|
||||
toHexFromFormat(
|
||||
props.modelValue || '#000000',
|
||||
isColorFormat(props.widget.options?.format)
|
||||
? props.widget.options.format
|
||||
: 'hex'
|
||||
)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
|
||||
}
|
||||
)
|
||||
|
||||
function onPickerUpdate(val: unknown) {
|
||||
localValue.value = val as PickerValue
|
||||
emit('update:modelValue', toHexFromFormat(val, format.value))
|
||||
function onUpdate(val: string) {
|
||||
localValue.value = val
|
||||
modelValue.value = val
|
||||
}
|
||||
|
||||
// ColorPicker specific excluded props include panel/overlay classes
|
||||
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user