Add QuickPropertyFilters Component and Enhance FilterInput and ObjectTable
- Introduced QuickPropertyFilters component for streamlined property filtering functionality. - Updated FilterInput to integrate QuickPropertyFilters, enhancing user experience with quick access to filter options. - Modified ObjectTable to include QuickPropertyFilters, improving filtering capabilities within the dashboard. - Adjusted styles in App.css for dropdown z-index and input font consistency.
This commit is contained in:
parent
1597370a16
commit
3352c25819
@ -47,6 +47,10 @@
|
||||
font-family: 'DM Sans';
|
||||
}
|
||||
|
||||
.ant-dropdown {
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.ant-typography code,
|
||||
.ant-typography pre,
|
||||
.ͼ1 .cm-scroller {
|
||||
@ -793,7 +797,7 @@ body {
|
||||
}
|
||||
|
||||
.input-number-cal .ant-input {
|
||||
font-family: 'DM Mono';
|
||||
font-family: 'DM Sans';
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import { useCompactItemContext } from 'antd/es/space/Compact'
|
||||
import useStyle, { useSharedStyle } from 'antd/es/input/style'
|
||||
import { useThemeContext } from '../context/ThemeContext'
|
||||
import SimplePropertyFilter from './SimplePropertyFilter'
|
||||
import QuickPropertyFilters from './QuickPropertyFilters'
|
||||
import ScrollBox from './ScrollBox'
|
||||
import ChevronRightIcon from '../../Icons/ChevronRightIcon'
|
||||
import GreaterThanIcon from '../../Icons/GreaterThanIcon'
|
||||
@ -25,9 +26,12 @@ import LessThanIcon from '../../Icons/LessThanIcon'
|
||||
import LessThanOrEqualToIcon from '../../Icons/LessThanOrEqualToIcon'
|
||||
import NotEqualIcon from '../../Icons/NotEqualIcon'
|
||||
import EqualIcon from '../../Icons/EqualIcon'
|
||||
import { Divider } from 'antd'
|
||||
|
||||
const operandIconStyle = { fontSize: 8 }
|
||||
|
||||
const PROPERTY_FILTER_PANEL_MAX_HEIGHT = 220
|
||||
|
||||
// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
|
||||
// Wildcards (* ?) are highlighted in place; @ stays as plain text — none are operands.
|
||||
const WILDCARDS = new Set(['*', '?'])
|
||||
@ -469,8 +473,12 @@ const FilterInput = ({
|
||||
const composingRef = useRef(false)
|
||||
const selectingRef = useRef(false)
|
||||
const valueRef = useRef(value ?? '')
|
||||
const quickFiltersRef = useRef(null)
|
||||
const quickFilterModalOpenRef = useRef(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [internalValue, setInternalValue] = useState(value ?? '')
|
||||
const [quickFiltersHeight, setQuickFiltersHeight] = useState(0)
|
||||
const [quickFilterModalOpen, setQuickFilterModalOpen] = useState(false)
|
||||
|
||||
const clearTagRoots = useCallback(() => {
|
||||
tagRootsRef.current.forEach((root) => {
|
||||
@ -864,6 +872,48 @@ const FilterInput = ({
|
||||
[emitChange, paint]
|
||||
)
|
||||
|
||||
const handleQuickFilterModalOpenChange = useCallback((open) => {
|
||||
quickFilterModalOpenRef.current = open
|
||||
setQuickFilterModalOpen(open)
|
||||
if (open) {
|
||||
editorRef.current?.blur()
|
||||
focusedRef.current = false
|
||||
setFocused(false)
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.current?.focus()
|
||||
focusedRef.current = true
|
||||
setFocused(true)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const propertyFilterPopoverOpen = focused || quickFilterModalOpen
|
||||
|
||||
useEffect(() => {
|
||||
if (!propertyFilterEnabled) {
|
||||
setQuickFiltersHeight(0)
|
||||
return
|
||||
}
|
||||
|
||||
const node = quickFiltersRef.current
|
||||
if (!node) return
|
||||
|
||||
const update = () => {
|
||||
setQuickFiltersHeight(node.getBoundingClientRect().height)
|
||||
}
|
||||
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [
|
||||
propertyFilterEnabled,
|
||||
propertyFilter?.modelType,
|
||||
propertyFilter?.propertyName,
|
||||
focused
|
||||
])
|
||||
|
||||
const propertyFilterContent = propertyFilterEnabled ? (
|
||||
<div
|
||||
onMouseDown={(event) => {
|
||||
@ -872,17 +922,33 @@ const FilterInput = ({
|
||||
}}
|
||||
className='filter-input-property-filter'
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: 280,
|
||||
maxHeight: 220,
|
||||
maxHeight: quickFiltersHeight + PROPERTY_FILTER_PANEL_MAX_HEIGHT,
|
||||
margin: -4,
|
||||
verticalAlign: 'top'
|
||||
verticalAlign: 'top',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div ref={quickFiltersRef} style={{ flexShrink: 0 }}>
|
||||
<QuickPropertyFilters
|
||||
modelType={propertyFilter.modelType}
|
||||
propertyName={propertyFilter.propertyName}
|
||||
onChange={handlePropertyFilterChange}
|
||||
onModalOpenChange={handleQuickFilterModalOpenChange}
|
||||
useCard={false}
|
||||
/>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
<ScrollBox
|
||||
inner
|
||||
smallPadding
|
||||
style={{ height: 'auto', maxHeight: 220, maxWidth: 280 }}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
maxHeight: PROPERTY_FILTER_PANEL_MAX_HEIGHT,
|
||||
maxWidth: 280
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@ -1031,7 +1097,9 @@ const FilterInput = ({
|
||||
zIndex: focused ? 3 : style?.zIndex
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!disabled) editorRef.current?.focus()
|
||||
if (!disabled && !quickFilterModalOpenRef.current) {
|
||||
editorRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@ -1100,6 +1168,11 @@ const FilterInput = ({
|
||||
onFocus?.(event)
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (quickFilterModalOpenRef.current) {
|
||||
focusedRef.current = false
|
||||
setFocused(false)
|
||||
return
|
||||
}
|
||||
focusedRef.current = false
|
||||
selectingRef.current = false
|
||||
setFocused(false)
|
||||
@ -1151,9 +1224,9 @@ const FilterInput = ({
|
||||
)}
|
||||
{propertyFilterEnabled && (
|
||||
<Popover
|
||||
open={focused}
|
||||
open={propertyFilterPopoverOpen}
|
||||
destroyOnHidden={true}
|
||||
content={focused ? propertyFilterContent : null}
|
||||
content={propertyFilterPopoverOpen ? propertyFilterContent : null}
|
||||
placement='bottomLeft'
|
||||
arrow={false}
|
||||
trigger={[]}
|
||||
|
||||
@ -49,6 +49,7 @@ import ActionsIcon from '../../Icons/ActionsIcon'
|
||||
import FilterIcon from '../../Icons/FilterIcon'
|
||||
import ScrollBox from './ScrollBox'
|
||||
import SimplePropertyFilter from './SimplePropertyFilter'
|
||||
import QuickPropertyFilters from './QuickPropertyFilters'
|
||||
import FilterInput from './FilterInput'
|
||||
import {
|
||||
getActiveFilterValues,
|
||||
@ -159,6 +160,11 @@ const ColumnFilterDropdown = ({
|
||||
<Button onClick={resetFilter} icon={<XMarkIcon />} />
|
||||
<Button type='primary' onClick={applyFilter} icon={<CheckIcon />} />
|
||||
</Space.Compact>
|
||||
<QuickPropertyFilters
|
||||
modelType={modelType}
|
||||
propertyName={propertyName}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
<Card size='small' styles={{ body: { padding: 0, height: 200 } }}>
|
||||
<ScrollBox inner={true} smallPadding={true}>
|
||||
<div style={{ padding: '18px 20px', minWidth: 0 }}>
|
||||
|
||||
443
src/components/Dashboard/common/QuickPropertyFilters.jsx
Normal file
443
src/components/Dashboard/common/QuickPropertyFilters.jsx
Normal file
@ -0,0 +1,443 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Flex,
|
||||
Input,
|
||||
Modal,
|
||||
Typography
|
||||
} from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import dayjs from 'dayjs'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
|
||||
import InputNumberCal from './InputNumberCal'
|
||||
import {
|
||||
getModelByName,
|
||||
getModelProperties
|
||||
} from '../../../database/ObjectModels'
|
||||
import { isDateTimePropertyType } from './filterExpression'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const TEXT_TYPES = new Set([
|
||||
'text',
|
||||
'email',
|
||||
'url',
|
||||
'codeBlock',
|
||||
'markdown',
|
||||
'tags',
|
||||
'stringList',
|
||||
'country',
|
||||
'phone',
|
||||
'address',
|
||||
'secret',
|
||||
'miscId'
|
||||
])
|
||||
|
||||
const NUMBER_TYPES = new Set(['number', 'density', 'variance', 'numberList'])
|
||||
|
||||
const BOOL_TYPES = new Set(['bool', 'boolean'])
|
||||
|
||||
const OBJECT_TYPES = new Set([
|
||||
'object',
|
||||
'objectList',
|
||||
'reference',
|
||||
'state',
|
||||
'id',
|
||||
'objectType'
|
||||
])
|
||||
|
||||
const formatFilterValue = (value, property) => {
|
||||
if (value == null || value === '') return ''
|
||||
if (typeof value !== 'object') return String(value)
|
||||
|
||||
if (property?.type === 'state') {
|
||||
return String(value.type ?? value)
|
||||
}
|
||||
|
||||
if (value.objectType) {
|
||||
const { prefix } = getModelByName(value.objectType)
|
||||
if (value._reference != null) return `${prefix}:${value._reference}`
|
||||
if (value._id != null) return `${prefix}:${value._id}`
|
||||
}
|
||||
|
||||
if (property?.objectType && value._id != null) {
|
||||
const { prefix } = getModelByName(property.objectType)
|
||||
return `${prefix}:${value._id}`
|
||||
}
|
||||
|
||||
return String(value._id ?? value.type ?? JSON.stringify(value))
|
||||
}
|
||||
|
||||
const formatDateFilterValue = (value, property) => {
|
||||
if (value == null || value === '') return ''
|
||||
const date = dayjs(value)
|
||||
if (!date.isValid()) return String(value)
|
||||
if (property?.type === 'date') {
|
||||
return `${date.date()} ${date.month() + 1} ${date.year()}`
|
||||
}
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
const buildExpression = (operator, rawValue, property) => {
|
||||
const value = isDateTimePropertyType(property)
|
||||
? formatDateFilterValue(rawValue, property)
|
||||
: formatFilterValue(rawValue, property)
|
||||
|
||||
switch (operator) {
|
||||
case 'startsWith':
|
||||
return `${value}*`
|
||||
case 'notStartsWith':
|
||||
return `<>${value}*`
|
||||
case 'contains':
|
||||
return `*${value}*`
|
||||
case 'notContains':
|
||||
return `<>*${value}*`
|
||||
case 'endsWith':
|
||||
return `*${value}`
|
||||
case 'notEndsWith':
|
||||
return `<>*${value}`
|
||||
case 'equals':
|
||||
return value
|
||||
case 'notEquals':
|
||||
return `<>${value}`
|
||||
case 'greaterThan':
|
||||
return `>${value}`
|
||||
case 'greaterOrEqual':
|
||||
return `>=${value}`
|
||||
case 'lessThan':
|
||||
return `<${value}`
|
||||
case 'lessOrEqual':
|
||||
return `<=${value}`
|
||||
case 'between':
|
||||
return value
|
||||
case 'isTrue':
|
||||
return 'true'
|
||||
case 'isFalse':
|
||||
return 'false'
|
||||
case 'isEmpty':
|
||||
return ''
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const getPropertyCategory = (property) => {
|
||||
const type = property?.type
|
||||
if (!type) return 'text'
|
||||
if (BOOL_TYPES.has(type)) return 'boolean'
|
||||
if (isDateTimePropertyType(property)) return 'date'
|
||||
if (NUMBER_TYPES.has(type)) return 'number'
|
||||
if (OBJECT_TYPES.has(type)) return 'object'
|
||||
if (TEXT_TYPES.has(type)) return 'text'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
const getFilterOptions = (category) => {
|
||||
switch (category) {
|
||||
case 'boolean':
|
||||
return [
|
||||
{ key: 'isTrue', label: 'Is true' },
|
||||
{ key: 'isFalse', label: 'Is false' }
|
||||
]
|
||||
case 'number':
|
||||
return [
|
||||
{ key: 'equals', label: 'Equals', needsValue: true },
|
||||
{ key: 'notEquals', label: 'Does not equal', needsValue: true },
|
||||
{ key: 'greaterThan', label: 'Greater than', needsValue: true },
|
||||
{
|
||||
key: 'greaterOrEqual',
|
||||
label: 'Greater than or equal',
|
||||
needsValue: true
|
||||
},
|
||||
{ key: 'lessThan', label: 'Less than', needsValue: true },
|
||||
{
|
||||
key: 'lessOrEqual',
|
||||
label: 'Less than or equal',
|
||||
needsValue: true
|
||||
},
|
||||
{ key: 'between', label: 'Between', needsRange: true }
|
||||
]
|
||||
case 'date':
|
||||
return [
|
||||
{ key: 'equals', label: 'On', needsValue: true },
|
||||
{ key: 'lessThan', label: 'Before', needsValue: true },
|
||||
{ key: 'greaterThan', label: 'After', needsValue: true },
|
||||
{ key: 'between', label: 'Between', needsRange: true }
|
||||
]
|
||||
case 'object':
|
||||
return [
|
||||
{ key: 'equals', label: 'Equals', needsValue: true },
|
||||
{ key: 'notEquals', label: 'Does not equal', needsValue: true }
|
||||
]
|
||||
case 'text':
|
||||
default:
|
||||
return [
|
||||
{ key: 'startsWith', label: 'Starts with', needsValue: true },
|
||||
{
|
||||
key: 'notStartsWith',
|
||||
label: 'Does not start with',
|
||||
needsValue: true
|
||||
},
|
||||
{ key: 'contains', label: 'Contains', needsValue: true },
|
||||
{ key: 'notContains', label: 'Does not contain', needsValue: true },
|
||||
{ key: 'endsWith', label: 'Ends with', needsValue: true },
|
||||
{ key: 'notEndsWith', label: 'Does not end with', needsValue: true },
|
||||
{ key: 'equals', label: 'Equals', needsValue: true },
|
||||
{ key: 'notEquals', label: 'Does not equal', needsValue: true },
|
||||
{ key: 'isEmpty', label: 'Is empty' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const QuickPropertyFilters = ({
|
||||
modelType,
|
||||
propertyName,
|
||||
onChange,
|
||||
onModalOpenChange,
|
||||
useCard = true
|
||||
}) => {
|
||||
const [activeOption, setActiveOption] = useState(null)
|
||||
const [inputValue, setInputValue] = useState(null)
|
||||
const [rangeFrom, setRangeFrom] = useState(null)
|
||||
const [rangeTo, setRangeTo] = useState(null)
|
||||
const modalContentRef = useRef(null)
|
||||
|
||||
const property = useMemo(
|
||||
() =>
|
||||
getModelProperties(modelType).find((prop) => prop.name === propertyName),
|
||||
[modelType, propertyName]
|
||||
)
|
||||
|
||||
const category = useMemo(() => getPropertyCategory(property), [property])
|
||||
|
||||
const options = useMemo(() => getFilterOptions(category), [category])
|
||||
|
||||
const propertyLabel = property?.label || propertyName
|
||||
|
||||
const closeModal = () => {
|
||||
onModalOpenChange?.(false)
|
||||
setActiveOption(null)
|
||||
setInputValue(null)
|
||||
setRangeFrom(null)
|
||||
setRangeTo(null)
|
||||
}
|
||||
|
||||
const applyFilter = (operator, rawValue) => {
|
||||
const expression = buildExpression(operator, rawValue, property)
|
||||
onChange?.([expression])
|
||||
closeModal()
|
||||
}
|
||||
|
||||
const handleOptionClick = (option) => {
|
||||
if (option.key === 'isTrue' || option.key === 'isFalse') {
|
||||
applyFilter(option.key)
|
||||
return
|
||||
}
|
||||
if (option.key === 'isEmpty') {
|
||||
applyFilter('isEmpty')
|
||||
return
|
||||
}
|
||||
setInputValue(null)
|
||||
setRangeFrom(null)
|
||||
setRangeTo(null)
|
||||
onModalOpenChange?.(true)
|
||||
setActiveOption(option)
|
||||
}
|
||||
|
||||
const handleModalOk = () => {
|
||||
if (!activeOption) return
|
||||
|
||||
if (activeOption.needsRange) {
|
||||
const from = isDateTimePropertyType(property)
|
||||
? formatDateFilterValue(rangeFrom, property)
|
||||
: formatFilterValue(rangeFrom, property)
|
||||
const to = isDateTimePropertyType(property)
|
||||
? formatDateFilterValue(rangeTo, property)
|
||||
: formatFilterValue(rangeTo, property)
|
||||
|
||||
if (!from && !to) return
|
||||
applyFilter('between', `${from}..${to}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
inputValue == null ||
|
||||
(typeof inputValue === 'string' && inputValue.trim() === '')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
applyFilter(activeOption.key, inputValue)
|
||||
}
|
||||
|
||||
const focusFirstModalInput = () => {
|
||||
requestAnimationFrame(() => {
|
||||
modalContentRef.current
|
||||
?.querySelector('input, textarea, .ant-picker-input input')
|
||||
?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const renderValueInput = (value, onValueChange) => {
|
||||
if (isDateTimePropertyType(property)) {
|
||||
const pickerValue =
|
||||
value == null ? null : dayjs.isDayjs(value) ? value : dayjs(value)
|
||||
const validValue =
|
||||
pickerValue && pickerValue.isValid() ? pickerValue : null
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
showTime={property?.type === 'dateTime'}
|
||||
value={validValue}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (NUMBER_TYPES.has(property?.type)) {
|
||||
return (
|
||||
<InputNumberCal
|
||||
style={{ width: '100%' }}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type='text'
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!property) return null
|
||||
|
||||
const content = (
|
||||
<Flex vertical gap={0}>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
type='text'
|
||||
block
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
textAlign: 'left',
|
||||
padding: '5px 12px'
|
||||
}}
|
||||
onClick={() => handleOptionClick(option)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{useCard ? (
|
||||
<Card size='small' styles={{ body: { padding: '4px 4px' } }}>
|
||||
{content}
|
||||
</Card>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
<Modal
|
||||
open={activeOption != null}
|
||||
onCancel={closeModal}
|
||||
afterOpenChange={(open) => {
|
||||
if (open) focusFirstModalInput()
|
||||
}}
|
||||
destroyOnHidden
|
||||
focusTriggerAfterClose={false}
|
||||
footer={null}
|
||||
centered
|
||||
closeIcon={null}
|
||||
getContainer={() => document.body}
|
||||
width={520}
|
||||
>
|
||||
{activeOption && (
|
||||
<Flex
|
||||
ref={modalContentRef}
|
||||
vertical
|
||||
gap='middle'
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Flex gap='middle'>
|
||||
<InfoCircleIcon />
|
||||
<Text strong>Filter by {propertyLabel}</Text>
|
||||
</Flex>
|
||||
<Text>
|
||||
{activeOption.needsRange
|
||||
? `Enter a range to continue:`
|
||||
: `Enter a value to continue:`}
|
||||
</Text>
|
||||
{activeOption.needsRange ? (
|
||||
<Flex vertical gap='middle'>
|
||||
<Descriptions
|
||||
column={1}
|
||||
size='small'
|
||||
styles={{ label: { width: '60px' } }}
|
||||
items={[
|
||||
{
|
||||
key: 'from',
|
||||
label: (
|
||||
<Flex
|
||||
vertical
|
||||
style={{ height: '100%' }}
|
||||
justify='center'
|
||||
>
|
||||
From
|
||||
</Flex>
|
||||
),
|
||||
children: renderValueInput(rangeFrom, setRangeFrom)
|
||||
},
|
||||
{
|
||||
key: 'to',
|
||||
label: (
|
||||
<Flex
|
||||
vertical
|
||||
style={{ height: '100%' }}
|
||||
justify='center'
|
||||
>
|
||||
To
|
||||
</Flex>
|
||||
),
|
||||
children: renderValueInput(rangeTo, setRangeTo)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
) : activeOption.needsValue ? (
|
||||
renderValueInput(inputValue, setInputValue)
|
||||
) : null}
|
||||
<Flex justify='end' gap='small'>
|
||||
<Button type='default' onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='primary' onClick={handleModalOk}>
|
||||
Apply
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
QuickPropertyFilters.propTypes = {
|
||||
modelType: PropTypes.string,
|
||||
propertyName: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
onModalOpenChange: PropTypes.func,
|
||||
useCard: PropTypes.bool
|
||||
}
|
||||
|
||||
export default QuickPropertyFilters
|
||||
Loading…
x
Reference in New Issue
Block a user