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;
|
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
|
.printerQueue.ant-steps.ant-steps-vertical
|
||||||
> .ant-steps-item:last-child
|
> .ant-steps-item:last-child
|
||||||
.ant-steps-item-content {
|
.ant-steps-item-content {
|
||||||
|
|||||||
@ -17,7 +17,8 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Divider,
|
Divider,
|
||||||
Typography,
|
Typography,
|
||||||
Popover
|
Popover,
|
||||||
|
theme
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
@ -46,6 +47,7 @@ import SettingsIcon from '../../Icons/SettingsIcon'
|
|||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import DashboardWindowButtons from './DashboardWindowButtons'
|
import DashboardWindowButtons from './DashboardWindowButtons'
|
||||||
import DashboardDevControls from './DashboardDevControls'
|
import DashboardDevControls from './DashboardDevControls'
|
||||||
|
import LiveActivities from './LiveActivities'
|
||||||
import WindowAppMenu from './WindowAppMenu'
|
import WindowAppMenu from './WindowAppMenu'
|
||||||
import WebAppSwitcher from './WebAppSwitcher'
|
import WebAppSwitcher from './WebAppSwitcher'
|
||||||
import DashboardTabs from './DashboardTabs'
|
import DashboardTabs from './DashboardTabs'
|
||||||
@ -148,6 +150,8 @@ const DashboardNavigationTrailingItems = memo(
|
|||||||
onNotifications,
|
onNotifications,
|
||||||
onCheckUpdates
|
onCheckUpdates
|
||||||
}) {
|
}) {
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex gap='small' align='center' style={NAV_TRAILING_FLEX_STYLE}>
|
<Flex gap='small' align='center' style={NAV_TRAILING_FLEX_STYLE}>
|
||||||
{controlsReady ? (
|
{controlsReady ? (
|
||||||
@ -165,6 +169,14 @@ const DashboardNavigationTrailingItems = memo(
|
|||||||
onClick={onSearch}
|
onClick={onSearch}
|
||||||
/>
|
/>
|
||||||
</KeyboardShortcut>
|
</KeyboardShortcut>
|
||||||
|
<Flex
|
||||||
|
align='center'
|
||||||
|
className='dashboard-navigation-activity-cluster'
|
||||||
|
style={{
|
||||||
|
'--dashboard-navigation-gap': `${token.paddingXS}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LiveActivities />
|
||||||
<Badge
|
<Badge
|
||||||
count={unreadCount}
|
count={unreadCount}
|
||||||
size='small'
|
size='small'
|
||||||
@ -184,6 +196,7 @@ const DashboardNavigationTrailingItems = memo(
|
|||||||
/>
|
/>
|
||||||
</KeyboardShortcut>
|
</KeyboardShortcut>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</Flex>
|
||||||
</Space>
|
</Space>
|
||||||
) : null}
|
) : null}
|
||||||
<DashboardDevControls />
|
<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'),
|
three: path.resolve(__dirname, 'node_modules/three'),
|
||||||
...codemirrorSingletons
|
...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: {
|
optimizeDeps: {
|
||||||
// Do not prebundle CodeMirror — pnpm + esbuild duplicates @codemirror/state
|
// Do not prebundle CodeMirror — pnpm + esbuild duplicates @codemirror/state
|
||||||
// and breaks FacetProvider instanceof checks.
|
// 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: [
|
exclude: [
|
||||||
'@uiw/react-codemirror',
|
'@uiw/react-codemirror',
|
||||||
'codemirror',
|
'codemirror',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user