Add Live Favicon Support and Update Live Activities Component
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Introduced functionality to dynamically update the favicon based on live activity status.
- Added CSS styles for the live activity favicon to enhance visual representation.
- Refactored LiveActivities component to capture and apply favicon updates, improving user experience with real-time notifications.
- Implemented utility functions for favicon management, ensuring efficient handling of favicon updates and restoration.
This commit is contained in:
Tom Butcher 2026-11-08 22:04:38 +00:00
parent f0b0ad41a6
commit 7574e1ef5b
2 changed files with 323 additions and 96 deletions

View File

@ -4349,6 +4349,48 @@ body.objectKanbanColumnResizing * {
--live-activity-flash-to: 0.45; --live-activity-flash-to: 0.45;
} }
.live-activity-favicon-source {
position: fixed;
top: 0;
left: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: 8px;
overflow: hidden;
pointer-events: none;
transform: translateX(-120vw);
z-index: -1;
}
.live-activity-favicon-source .live-activity {
margin: 0;
padding: 0;
border: 1px solid var(--live-activity-color);
border-radius: 8px;
box-sizing: border-box;
box-shadow: none;
cursor: default;
background: color-mix(
in srgb,
var(--live-activity-color) 45%,
var(--color-background)
);
}
.live-activity-favicon-source .live-activity-progress-fill {
opacity: 0.92;
}
.live-activity-favicon-source .live-activity-flash {
--live-activity-flash-from: 0.55;
--live-activity-flash-to: 0.92;
}
.live-activity-favicon-source .live-activity-progress-wave {
display: none;
}
.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 {

View File

@ -7,6 +7,8 @@ import {
useRef, useRef,
useState useState
} from 'react' } from 'react'
import { createPortal } from 'react-dom'
import { snapdom } from '@zumer/snapdom'
import { theme, Popover } from 'antd' import { theme, Popover } from 'antd'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
@ -61,6 +63,127 @@ const STATUS_COLOR_VAR = {
} }
const LIVE_ACTIVITY_TRANSITION_MS = 300 const LIVE_ACTIVITY_TRANSITION_MS = 300
const FAVICON_PX = 32
let faviconOriginalHref
let faviconOriginalType
let faviconAppliedHref
let faviconObjectUrl
let faviconCaptureChain = Promise.resolve()
const faviconLink = () => document.querySelector('link[rel="icon"]')
const tightenFaviconSvg = (src) => {
const comma = src.indexOf(',')
if (comma < 0 || !src.slice(0, comma).includes('svg')) return src
let svg = decodeURIComponent(src.slice(comma + 1))
svg = svg.replace(/viewBox="0 0 ([\d.]+) ([\d.]+)"/, (match, w, h) => {
const width = Number(w)
const height = Number(h)
if (!Number.isFinite(width) || width < 4 || width !== height) return match
return `viewBox="1 1 ${width - 2} ${height - 2}"`
})
return `${src.slice(0, comma)},${encodeURIComponent(svg)}`
}
const applyLiveFavicon = (href) => {
const tightened = href ? tightenFaviconSvg(href) : href
if (!tightened || tightened === faviconAppliedHref) return
const current = faviconLink()
if (!current) return
if (faviconOriginalHref === undefined) {
faviconOriginalHref = current.getAttribute('href')
faviconOriginalType = current.getAttribute('type')
}
const next = document.createElement('link')
next.rel = 'icon'
next.type = 'image/svg+xml'
next.href = tightened
current.replaceWith(next)
if (faviconObjectUrl && faviconObjectUrl !== tightened) {
URL.revokeObjectURL(faviconObjectUrl)
}
faviconObjectUrl = tightened.startsWith('blob:') ? tightened : null
faviconAppliedHref = tightened
}
const restoreLiveFavicon = () => {
if (faviconAppliedHref == null) return
const current = faviconLink()
faviconAppliedHref = null
if (faviconObjectUrl) {
URL.revokeObjectURL(faviconObjectUrl)
faviconObjectUrl = null
}
if (!current || faviconOriginalHref == null) return
const next = document.createElement('link')
next.rel = 'icon'
if (faviconOriginalType) next.type = faviconOriginalType
next.href = faviconOriginalHref
current.replaceWith(next)
}
const waitForPaint = () =>
new Promise((resolve) => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(resolve)
})
})
const renderLiveActivityFace = ({
activity,
size,
iconSize,
label,
tabIndex = 0,
onClick
}) => {
const colorVar = STATUS_COLOR_VAR[activity.status] || '--color-info'
const color = `var(${colorVar})`
return (
<button
type='button'
className='live-activity'
aria-label={label}
aria-hidden={onClick ? undefined : true}
tabIndex={onClick ? tabIndex : -1}
onClick={onClick}
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>
)
}
const readState = (object) => { const readState = (object) => {
const raw = object?.state ?? object?.status const raw = object?.state ?? object?.status
@ -340,110 +463,172 @@ const LiveActivities = () => {
const size = token.controlHeight const size = token.controlHeight
const iconSize = Math.max(12, Math.round(size * 0.5)) const iconSize = Math.max(12, Math.round(size * 0.5))
const faviconIconSize = Math.round(size * 0.66)
const openCount = slots.filter((slot) => slot.open).length const openCount = slots.filter((slot) => slot.open).length
const activitiesOpen = openCount > 0 const activitiesOpen = openCount > 0
return ( const faviconActivity = useMemo(
<div () =>
className={`live-activities${activitiesOpen ? ' live-activities-open' : ''}`} activities.reduce((best, activity) => {
aria-hidden={activitiesOpen ? undefined : true} if (activity.percent == null) return best
style={{ if (!best || activity.percent > best.percent) return activity
width: openCount * size, return best
height: size, }, null),
marginTop: 2, [activities]
opacity: activitiesOpen ? 1 : 0, )
pointerEvents: activitiesOpen ? undefined : 'none' const faviconSourceRef = useRef(null)
}} const faviconCaptureSeq = useRef(0)
> const faviconSignature = faviconActivity
<div className='live-activities-row'> ? [
{slots.map((activity) => { faviconActivity.key,
const colorVar = STATUS_COLOR_VAR[activity.status] || '--color-info' faviconActivity.percent,
const color = `var(${colorVar})` faviconActivity.status,
const tooltip = size,
activity.percent == null faviconIconSize,
? `${activity.name} · ${activity.text}` FAVICON_PX,
: `${activity.name} · ${activity.text} ${activity.percent}%` token.colorText,
const open = () => { token.colorInfo,
if (!activity.open || typeof activity.model.url !== 'string') return token.colorSuccess,
navigate( token.colorWarning,
`${activity.model.url}/info?${activity.model.name}Id=${activity.id}` token.colorError
) ].join('|')
} : ''
return ( useEffect(() => () => restoreLiveFavicon(), [])
<Popover
key={activity.key} useEffect(() => {
content={ const seq = ++faviconCaptureSeq.current
<SpotlightTooltip let cancelled = false
query={`${activity.model.prefix}:${activity.id}`}
type={activity.model.name} faviconCaptureChain = faviconCaptureChain
/> .catch(() => undefined)
} .then(async () => {
trigger='hover' if (cancelled || seq !== faviconCaptureSeq.current) return
placement='bottomRight' await waitForPaint()
arrow={false} if (cancelled || seq !== faviconCaptureSeq.current) return
style={{ padding: 0 }}
> const face = faviconSourceRef.current?.querySelector('.live-activity')
<div if (!face) {
className='live-activity-slot' restoreLiveFavicon()
style={{ width: activity.open ? size : 0 }} return
onTransitionEnd={(event) => { }
if (event.target !== event.currentTarget) return
if (event.propertyName !== 'width') return try {
if (activity.open) return const image = await snapdom.toSvg(face, {
setSlots((current) => width: FAVICON_PX,
current.filter((slot) => slot.key !== activity.key) height: FAVICON_PX,
) embedFonts: false,
}} backgroundColor: null,
dpr: 1,
scale: 1,
outerTransforms: false,
invalidate: true
})
if (
cancelled ||
seq !== faviconCaptureSeq.current ||
!image?.src
) {
return
}
applyLiveFavicon(image.src)
} catch (error) {
if (!cancelled && seq === faviconCaptureSeq.current) {
console.error('Failed to capture live activity favicon', error)
}
}
})
return () => {
cancelled = true
}
}, [faviconSignature])
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 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 }}
> >
<button <div
type='button' className='live-activity-slot'
className='live-activity' style={{ width: activity.open ? size : 0 }}
aria-label={tooltip} onTransitionEnd={(event) => {
tabIndex={activity.open ? 0 : -1} if (event.target !== event.currentTarget) return
onClick={open} if (event.propertyName !== 'width') return
style={{ if (activity.open) return
width: size, setSlots((current) =>
height: size, current.filter((slot) => slot.key !== activity.key)
'--live-activity-color': color )
}} }}
> >
<span {renderLiveActivityFace({
className={ activity,
activity.percent == null size,
? 'live-activity-flash' iconSize,
: 'live-activity-progress' label: tooltip,
} tabIndex: activity.open ? 0 : -1,
style={{ onClick: open
background: activity.percent == null ? color : undefined, })}
width: </div>
activity.percent == null </Popover>
? undefined )
: `${activity.percent}%` })}
}} </div>
>
{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>
</div> {faviconActivity
? createPortal(
<div
className='live-activity-favicon-source'
aria-hidden='true'
ref={faviconSourceRef}
style={{ width: size, height: size }}
>
{renderLiveActivityFace({
activity: faviconActivity,
size,
iconSize: faviconIconSize
})}
</div>,
document.body
)
: null}
</>
) )
} }