Enhance Dashboard Navigation and Live Activities Integration
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Updated vite.config.js to optimize React chunking for antd imports, preventing issues with hooks. - Added new LiveActivities component to manage and display real-time activity notifications in the dashboard. - Refactored DashboardNavigation component to include LiveActivities, improving user interaction with activity notifications. - Introduced new CSS styles for dashboard navigation activities, enhancing visual feedback and responsiveness.
This commit is contained in:
parent
b3e366e813
commit
2e1375ce2d
@ -4208,6 +4208,142 @@ body.objectKanbanColumnResizing * {
|
||||
stroke-width: 12px;
|
||||
}
|
||||
|
||||
.dashboard-navigation-activity-cluster {
|
||||
gap: 0;
|
||||
transition: gap 0.3s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.dashboard-navigation-activity-cluster:has(.live-activities-open) {
|
||||
gap: var(--dashboard-navigation-gap, 8px);
|
||||
}
|
||||
|
||||
.live-activities {
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
flex: 0 0 auto;
|
||||
transition:
|
||||
width 0.3s cubic-bezier(0.2, 0, 0, 1),
|
||||
opacity 0.3s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.live-activities-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.live-activity-slot {
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transition: width 0.3s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.live-activity {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
container-type: inline-size;
|
||||
background: color-mix(in srgb, var(--live-activity-color) 20%, transparent);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.live-activity:hover {
|
||||
background: color-mix(in srgb, var(--live-activity-color) 35%, transparent);
|
||||
}
|
||||
|
||||
.live-activity-progress,
|
||||
.live-activity-flash {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.live-activity-flash {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.live-activity-progress {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.live-activity-progress-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.22;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.live-activity:hover .live-activity-progress-fill {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.live-activity-progress-wave {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: -80cqw;
|
||||
width: 240cqw;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0,
|
||||
rgba(255, 255, 255, 0.05) 40cqw,
|
||||
rgba(255, 255, 255, 0) 80cqw
|
||||
);
|
||||
animation: live-activity-progress-wave 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes live-activity-progress-wave {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(100% / 3));
|
||||
}
|
||||
}
|
||||
|
||||
.live-activity-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
line-height: 1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@keyframes live-activity-flash {
|
||||
0%,
|
||||
100% {
|
||||
opacity: var(--live-activity-flash-from);
|
||||
}
|
||||
50% {
|
||||
opacity: var(--live-activity-flash-to);
|
||||
}
|
||||
}
|
||||
|
||||
.live-activity-flash {
|
||||
--live-activity-flash-from: 0;
|
||||
--live-activity-flash-to: 0.2;
|
||||
animation: live-activity-flash 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.live-activity:hover .live-activity-flash {
|
||||
--live-activity-flash-from: 0.12;
|
||||
--live-activity-flash-to: 0.45;
|
||||
}
|
||||
|
||||
.printerQueue.ant-steps.ant-steps-vertical
|
||||
> .ant-steps-item:last-child
|
||||
.ant-steps-item-content {
|
||||
|
||||
@ -17,7 +17,8 @@ import {
|
||||
Badge,
|
||||
Divider,
|
||||
Typography,
|
||||
Popover
|
||||
Popover,
|
||||
theme
|
||||
} from 'antd'
|
||||
import classNames from 'classnames'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
@ -46,6 +47,7 @@ import SettingsIcon from '../../Icons/SettingsIcon'
|
||||
import { ElectronContext } from '../context/ElectronContext'
|
||||
import DashboardWindowButtons from './DashboardWindowButtons'
|
||||
import DashboardDevControls from './DashboardDevControls'
|
||||
import LiveActivities from './LiveActivities'
|
||||
import WindowAppMenu from './WindowAppMenu'
|
||||
import WebAppSwitcher from './WebAppSwitcher'
|
||||
import DashboardTabs from './DashboardTabs'
|
||||
@ -148,6 +150,8 @@ const DashboardNavigationTrailingItems = memo(
|
||||
onNotifications,
|
||||
onCheckUpdates
|
||||
}) {
|
||||
const { token } = theme.useToken()
|
||||
|
||||
return (
|
||||
<Flex gap='small' align='center' style={NAV_TRAILING_FLEX_STYLE}>
|
||||
{controlsReady ? (
|
||||
@ -165,6 +169,14 @@ const DashboardNavigationTrailingItems = memo(
|
||||
onClick={onSearch}
|
||||
/>
|
||||
</KeyboardShortcut>
|
||||
<Flex
|
||||
align='center'
|
||||
className='dashboard-navigation-activity-cluster'
|
||||
style={{
|
||||
'--dashboard-navigation-gap': `${token.paddingXS}px`
|
||||
}}
|
||||
>
|
||||
<LiveActivities />
|
||||
<Badge
|
||||
count={unreadCount}
|
||||
size='small'
|
||||
@ -184,6 +196,7 @@ const DashboardNavigationTrailingItems = memo(
|
||||
/>
|
||||
</KeyboardShortcut>
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Space>
|
||||
) : null}
|
||||
<DashboardDevControls />
|
||||
|
||||
450
src/components/Dashboard/common/LiveActivities.jsx
Normal file
450
src/components/Dashboard/common/LiveActivities.jsx
Normal file
@ -0,0 +1,450 @@
|
||||
import {
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { theme, Popover } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import { getModelByName } from '../../../database/ObjectModels'
|
||||
import { getStateTagInfo } from '../utils/Utils'
|
||||
import SpotlightTooltip from './SpotlightTooltip'
|
||||
|
||||
const ACTIVITY_TYPES = new Set([
|
||||
'printing',
|
||||
'processing',
|
||||
'deploying',
|
||||
'loading',
|
||||
'queued',
|
||||
'syncing',
|
||||
'publishing',
|
||||
'unpublishing',
|
||||
'initializing',
|
||||
'rendering',
|
||||
'sending',
|
||||
'connecting',
|
||||
'downloading',
|
||||
'startup',
|
||||
'paused',
|
||||
'used'
|
||||
])
|
||||
|
||||
const PROGRESS_TYPES = new Set([
|
||||
'loading',
|
||||
'processing',
|
||||
'queued',
|
||||
'printing',
|
||||
'used',
|
||||
'deploying',
|
||||
'syncing',
|
||||
'publishing',
|
||||
'unpublishing'
|
||||
])
|
||||
|
||||
const STATUS_COLOR_VAR = {
|
||||
success: '--color-success',
|
||||
warning: '--color-warning',
|
||||
error: '--color-error',
|
||||
processing: '--color-info',
|
||||
info: '--color-info',
|
||||
default: '--color-info',
|
||||
cyan: '--color-cyan',
|
||||
purple: '--color-purple',
|
||||
magenta: '--color-magenta',
|
||||
volcano: '--color-volcano',
|
||||
pink: '--color-pink'
|
||||
}
|
||||
|
||||
const LIVE_ACTIVITY_TRANSITION_MS = 300
|
||||
|
||||
const readState = (object) => {
|
||||
const raw = object?.state ?? object?.status
|
||||
if (raw && typeof raw === 'object') {
|
||||
return {
|
||||
type: raw.type,
|
||||
progress: raw.progress,
|
||||
message: raw.message
|
||||
}
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
return { type: raw }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const hasStateField = (object) => {
|
||||
if (!object || typeof object !== 'object') return false
|
||||
return object.state != null || object.status != null
|
||||
}
|
||||
|
||||
const progressPercent = (state) => {
|
||||
if (!state || !PROGRESS_TYPES.has(state.type)) return null
|
||||
const progress = Number(state.progress)
|
||||
if (!Number.isFinite(progress) || progress <= 0) return null
|
||||
const percent =
|
||||
progress <= 1
|
||||
? Math.round(progress * 1000) / 10
|
||||
: Math.round(progress * 10) / 10
|
||||
if (percent <= 0 || percent >= 100) return null
|
||||
return percent
|
||||
}
|
||||
|
||||
const objectIdOf = (value) => {
|
||||
if (!value) return null
|
||||
if (typeof value === 'string') return value
|
||||
return value._id ?? null
|
||||
}
|
||||
|
||||
const mergeObjectPatch = (current, patch) => {
|
||||
if (!patch || typeof patch !== 'object') return current
|
||||
const next = { ...(current || {}), ...patch }
|
||||
if (
|
||||
patch.state &&
|
||||
typeof patch.state === 'object' &&
|
||||
current?.state &&
|
||||
typeof current.state === 'object'
|
||||
) {
|
||||
next.state = { ...current.state, ...patch.state }
|
||||
}
|
||||
if (
|
||||
patch.status &&
|
||||
typeof patch.status === 'object' &&
|
||||
current?.status &&
|
||||
typeof current.status === 'object'
|
||||
) {
|
||||
next.status = { ...current.status, ...patch.status }
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
const LiveActivities = () => {
|
||||
const { token } = theme.useToken()
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
userProfile,
|
||||
token: authToken,
|
||||
authInitialized
|
||||
} = useContext(AuthContext)
|
||||
const {
|
||||
fetchObjects,
|
||||
connected,
|
||||
subscribeToObjectTypeUpdates,
|
||||
subscribeToObjectUpdates
|
||||
} = useContext(ApiServerContext)
|
||||
const [watched, setWatched] = useState([])
|
||||
|
||||
const userId = userProfile?._id
|
||||
const authReady = Boolean(authToken && authInitialized && userId)
|
||||
|
||||
const apiRef = useRef({ fetchObjects })
|
||||
apiRef.current = { fetchObjects }
|
||||
const watchedRef = useRef(watched)
|
||||
watchedRef.current = watched
|
||||
|
||||
const loadNotifiers = useCallback(async () => {
|
||||
if (!userId) {
|
||||
setWatched([])
|
||||
return
|
||||
}
|
||||
|
||||
const result = await apiRef.current.fetchObjects('userNotifier', {
|
||||
filter: { user: userId },
|
||||
limit: 200
|
||||
})
|
||||
const notifiers = Array.isArray(result?.data)
|
||||
? result.data
|
||||
: Array.isArray(result)
|
||||
? result
|
||||
: []
|
||||
|
||||
setWatched(
|
||||
notifiers
|
||||
.map((notifier) => {
|
||||
const objectId = objectIdOf(notifier?.object)
|
||||
if (!objectId || !notifier?.objectType) return null
|
||||
const object =
|
||||
notifier.object && typeof notifier.object === 'object'
|
||||
? notifier.object
|
||||
: { _id: objectId }
|
||||
return {
|
||||
notifierId: notifier._id,
|
||||
objectType: notifier.objectType,
|
||||
object
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
}, [userId])
|
||||
|
||||
const loadNotifiersRef = useRef(loadNotifiers)
|
||||
loadNotifiersRef.current = loadNotifiers
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady || !connected) {
|
||||
if (!authReady) setWatched([])
|
||||
return undefined
|
||||
}
|
||||
loadNotifiersRef.current()
|
||||
return undefined
|
||||
}, [authReady, connected])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady || connected !== true || !userId) return undefined
|
||||
const unsubscribe = subscribeToObjectTypeUpdates(
|
||||
'userNotifier',
|
||||
{ user: userId },
|
||||
() => {
|
||||
loadNotifiersRef.current()
|
||||
}
|
||||
)
|
||||
return () => unsubscribe?.()
|
||||
}, [authReady, connected, subscribeToObjectTypeUpdates, userId])
|
||||
|
||||
const subscriptionKey = useMemo(
|
||||
() =>
|
||||
watched
|
||||
.filter((item) => hasStateField(item.object))
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.objectType}:${String(item.object._id).toLowerCase()}`
|
||||
)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[watched]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (connected !== true || !subscriptionKey) return undefined
|
||||
|
||||
const unsubscribes = watchedRef.current
|
||||
.filter((item) => hasStateField(item.object))
|
||||
.map((item) => {
|
||||
const id = String(item.object._id).toLowerCase()
|
||||
return subscribeToObjectUpdates(id, item.objectType, (patch) => {
|
||||
setWatched((prev) =>
|
||||
prev.map((entry) => {
|
||||
const entryId = entry.object?._id
|
||||
if (!entryId || String(entryId).toLowerCase() !== id) return entry
|
||||
if (entry.objectType !== item.objectType) return entry
|
||||
return {
|
||||
...entry,
|
||||
object: mergeObjectPatch(entry.object, patch)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe?.())
|
||||
}
|
||||
}, [connected, subscriptionKey, subscribeToObjectUpdates])
|
||||
|
||||
const activities = useMemo(
|
||||
() =>
|
||||
watched.flatMap((item) => {
|
||||
const state = readState(item.object)
|
||||
if (!state?.type || !ACTIVITY_TYPES.has(state.type)) return []
|
||||
const model = getModelByName(item.objectType)
|
||||
const { status, text } = getStateTagInfo(state.type)
|
||||
const percent = progressPercent(state)
|
||||
const name = item.object?.name || model.label
|
||||
return [
|
||||
{
|
||||
key: `${item.objectType}:${item.object._id}`,
|
||||
id: item.object._id,
|
||||
name,
|
||||
model,
|
||||
status,
|
||||
text,
|
||||
percent
|
||||
}
|
||||
]
|
||||
}),
|
||||
[watched]
|
||||
)
|
||||
|
||||
const [slots, setSlots] = useState([])
|
||||
const activityKeysRef = useRef(new Set())
|
||||
activityKeysRef.current = new Set(activities.map((activity) => activity.key))
|
||||
|
||||
useEffect(() => {
|
||||
setSlots((prev) => {
|
||||
const seen = new Set()
|
||||
const next = []
|
||||
|
||||
for (const slot of prev) {
|
||||
const fresh = activities.find((activity) => activity.key === slot.key)
|
||||
if (fresh) {
|
||||
next.push({ ...fresh, open: slot.open })
|
||||
seen.add(slot.key)
|
||||
} else {
|
||||
next.push({ ...slot, open: false })
|
||||
}
|
||||
}
|
||||
|
||||
for (const activity of activities) {
|
||||
if (seen.has(activity.key)) continue
|
||||
next.push({ ...activity, open: false })
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [activities])
|
||||
|
||||
useEffect(() => {
|
||||
const needsOpen = slots.some(
|
||||
(slot) => !slot.open && activityKeysRef.current.has(slot.key)
|
||||
)
|
||||
if (!needsOpen) return undefined
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
setSlots((current) => {
|
||||
const live = activityKeysRef.current
|
||||
let changed = false
|
||||
const next = current.map((slot) => {
|
||||
if (!slot.open && live.has(slot.key)) {
|
||||
changed = true
|
||||
return { ...slot, open: true }
|
||||
}
|
||||
return slot
|
||||
})
|
||||
return changed ? next : current
|
||||
})
|
||||
})
|
||||
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [slots])
|
||||
|
||||
useEffect(() => {
|
||||
const closing = slots.some(
|
||||
(slot) => !slot.open && !activityKeysRef.current.has(slot.key)
|
||||
)
|
||||
if (!closing) return undefined
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setSlots((current) =>
|
||||
current.filter(
|
||||
(slot) => slot.open || activityKeysRef.current.has(slot.key)
|
||||
)
|
||||
)
|
||||
}, LIVE_ACTIVITY_TRANSITION_MS + 40)
|
||||
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [slots])
|
||||
|
||||
const size = token.controlHeight
|
||||
const iconSize = Math.max(12, Math.round(size * 0.5))
|
||||
const openCount = slots.filter((slot) => slot.open).length
|
||||
const activitiesOpen = openCount > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`live-activities${activitiesOpen ? ' live-activities-open' : ''}`}
|
||||
aria-hidden={activitiesOpen ? undefined : true}
|
||||
style={{
|
||||
width: openCount * size,
|
||||
height: size,
|
||||
marginTop: 2,
|
||||
opacity: activitiesOpen ? 1 : 0,
|
||||
pointerEvents: activitiesOpen ? undefined : 'none'
|
||||
}}
|
||||
>
|
||||
<div className='live-activities-row'>
|
||||
{slots.map((activity) => {
|
||||
const colorVar = STATUS_COLOR_VAR[activity.status] || '--color-info'
|
||||
const color = `var(${colorVar})`
|
||||
const tooltip =
|
||||
activity.percent == null
|
||||
? `${activity.name} · ${activity.text}`
|
||||
: `${activity.name} · ${activity.text} ${activity.percent}%`
|
||||
const open = () => {
|
||||
if (!activity.open || typeof activity.model.url !== 'string') return
|
||||
navigate(
|
||||
`${activity.model.url}/info?${activity.model.name}Id=${activity.id}`
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
key={activity.key}
|
||||
content={
|
||||
<SpotlightTooltip
|
||||
query={`${activity.model.prefix}:${activity.id}`}
|
||||
type={activity.model.name}
|
||||
/>
|
||||
}
|
||||
trigger='hover'
|
||||
placement='bottomRight'
|
||||
arrow={false}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
<div
|
||||
className='live-activity-slot'
|
||||
style={{ width: activity.open ? size : 0 }}
|
||||
onTransitionEnd={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.propertyName !== 'width') return
|
||||
if (activity.open) return
|
||||
setSlots((current) =>
|
||||
current.filter((slot) => slot.key !== activity.key)
|
||||
)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='live-activity'
|
||||
aria-label={tooltip}
|
||||
tabIndex={activity.open ? 0 : -1}
|
||||
onClick={open}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
'--live-activity-color': color
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
activity.percent == null
|
||||
? 'live-activity-flash'
|
||||
: 'live-activity-progress'
|
||||
}
|
||||
style={{
|
||||
background: activity.percent == null ? color : undefined,
|
||||
width:
|
||||
activity.percent == null
|
||||
? undefined
|
||||
: `${activity.percent}%`
|
||||
}}
|
||||
>
|
||||
{activity.percent == null ? null : (
|
||||
<>
|
||||
<span
|
||||
className='live-activity-progress-fill'
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span className='live-activity-progress-wave' />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className='live-activity-icon'>
|
||||
{createElement(activity.model.icon, {
|
||||
style: { fontSize: iconSize }
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LiveActivities
|
||||
@ -50,12 +50,29 @@ export default defineConfig({
|
||||
three: path.resolve(__dirname, 'node_modules/three'),
|
||||
...codemirrorSingletons
|
||||
},
|
||||
dedupe: Object.keys(codemirrorSingletons)
|
||||
// Deep `antd/es/*` imports are optimized separately from the app entry.
|
||||
// Without dedupe they get their own React chunk, and hooks throw
|
||||
// "Cannot read properties of null (reading 'useContext')".
|
||||
dedupe: ['react', 'react-dom', ...Object.keys(codemirrorSingletons)]
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Do not prebundle CodeMirror — pnpm + esbuild duplicates @codemirror/state
|
||||
// and breaks FacetProvider instanceof checks.
|
||||
include: ['@ant-design/icons'],
|
||||
// Include antd's internal entry points up front so they share the app's
|
||||
// React chunk instead of being discovered later and bundled apart.
|
||||
include: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'react/jsx-dev-runtime',
|
||||
'antd',
|
||||
'antd/es/config-provider/hooks/useCSSVarCls',
|
||||
'antd/es/config-provider/hooks/useSize',
|
||||
'antd/es/input/style',
|
||||
'antd/es/space/Compact',
|
||||
'antd/es/layout/layout',
|
||||
'@ant-design/icons'
|
||||
],
|
||||
exclude: [
|
||||
'@uiw/react-codemirror',
|
||||
'codemirror',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user