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-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
> .ant-steps-item:last-child
.ant-steps-item-content {

View File

@ -7,6 +7,8 @@ import {
useRef,
useState
} from 'react'
import { createPortal } from 'react-dom'
import { snapdom } from '@zumer/snapdom'
import { theme, Popover } from 'antd'
import { useNavigate } from 'react-router-dom'
import { ApiServerContext } from '../context/ApiServerContext'
@ -61,6 +63,127 @@ const STATUS_COLOR_VAR = {
}
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 raw = object?.state ?? object?.status
@ -340,110 +463,172 @@ const LiveActivities = () => {
const size = token.controlHeight
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 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}`
)
}
const faviconActivity = useMemo(
() =>
activities.reduce((best, activity) => {
if (activity.percent == null) return best
if (!best || activity.percent > best.percent) return activity
return best
}, null),
[activities]
)
const faviconSourceRef = useRef(null)
const faviconCaptureSeq = useRef(0)
const faviconSignature = faviconActivity
? [
faviconActivity.key,
faviconActivity.percent,
faviconActivity.status,
size,
faviconIconSize,
FAVICON_PX,
token.colorText,
token.colorInfo,
token.colorSuccess,
token.colorWarning,
token.colorError
].join('|')
: ''
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)
)
}}
useEffect(() => () => restoreLiveFavicon(), [])
useEffect(() => {
const seq = ++faviconCaptureSeq.current
let cancelled = false
faviconCaptureChain = faviconCaptureChain
.catch(() => undefined)
.then(async () => {
if (cancelled || seq !== faviconCaptureSeq.current) return
await waitForPaint()
if (cancelled || seq !== faviconCaptureSeq.current) return
const face = faviconSourceRef.current?.querySelector('.live-activity')
if (!face) {
restoreLiveFavicon()
return
}
try {
const image = await snapdom.toSvg(face, {
width: FAVICON_PX,
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
type='button'
className='live-activity'
aria-label={tooltip}
tabIndex={activity.open ? 0 : -1}
onClick={open}
style={{
width: size,
height: size,
'--live-activity-color': color
<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)
)
}}
>
<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>
)
})}
{renderLiveActivityFace({
activity,
size,
iconSize,
label: tooltip,
tabIndex: activity.open ? 0 : -1,
onClick: open
})}
</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}
</>
)
}