All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Replace unicode characters, emoji, and inline SVGs with lucide-react components across the entire design system and page layer. Update tests to assert on SVG elements instead of text content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
209 lines
6.2 KiB
TypeScript
209 lines
6.2 KiB
TypeScript
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
|
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
|
|
import styles from './EventFeed.module.css'
|
|
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
|
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
|
|
|
export interface FeedEvent {
|
|
id: string
|
|
severity: 'error' | 'warning' | 'success' | 'running'
|
|
message: string | ReactNode
|
|
/** Plain-text version of message for search filtering (required when message is ReactNode) */
|
|
searchText?: string
|
|
icon?: ReactNode
|
|
timestamp: Date
|
|
}
|
|
|
|
type SeverityFilter = 'error' | 'warning' | 'success' | 'running'
|
|
|
|
interface EventFeedProps {
|
|
events: FeedEvent[]
|
|
maxItems?: number
|
|
className?: string
|
|
}
|
|
|
|
function formatRelativeTime(date: Date): string {
|
|
const now = Date.now()
|
|
const diff = now - date.getTime()
|
|
if (diff < 1000) return 'just now'
|
|
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
|
return `${Math.floor(diff / 86_400_000)}d ago`
|
|
}
|
|
|
|
function formatAbsoluteTime(date: Date): string {
|
|
const day = date.getDate()
|
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
|
const month = months[date.getMonth()]
|
|
const year = date.getFullYear()
|
|
const h = String(date.getHours()).padStart(2, '0')
|
|
const m = String(date.getMinutes()).padStart(2, '0')
|
|
return `${day} ${month} ${year}, ${h}:${m}`
|
|
}
|
|
|
|
function getSearchableText(event: FeedEvent): string {
|
|
if (event.searchText) return event.searchText
|
|
if (typeof event.message === 'string') return event.message
|
|
return ''
|
|
}
|
|
|
|
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
|
|
error: <XIcon size={14} />,
|
|
warning: <AlertTriangle size={14} />,
|
|
success: <Play size={14} />,
|
|
running: <Loader size={14} />,
|
|
}
|
|
|
|
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
|
error: 'var(--error)',
|
|
warning: 'var(--warning)',
|
|
success: 'var(--success)',
|
|
running: 'var(--running)',
|
|
}
|
|
|
|
const SEVERITY_LABELS: Record<SeverityFilter, string> = {
|
|
error: 'Error',
|
|
warning: 'Warning',
|
|
success: 'Success',
|
|
running: 'Info',
|
|
}
|
|
|
|
export function EventFeed({ events, maxItems = 200, className }: EventFeedProps) {
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
const [isPaused, setIsPaused] = useState(false)
|
|
const [activeFilters, setActiveFilters] = useState<Set<SeverityFilter>>(new Set())
|
|
const [search, setSearch] = useState('')
|
|
|
|
const searchLower = search.toLowerCase()
|
|
|
|
const displayed = events
|
|
.slice(-maxItems)
|
|
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
|
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
|
|
|
// Auto-scroll to top (newest entries are at top in desc sort)
|
|
const scrollToTop = useCallback(() => {
|
|
const el = scrollRef.current
|
|
if (el) {
|
|
el.scrollTop = 0
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!isPaused) {
|
|
scrollToTop()
|
|
}
|
|
}, [events, isPaused, scrollToTop])
|
|
|
|
function handleScroll() {
|
|
const el = scrollRef.current
|
|
if (!el) return
|
|
const atTop = el.scrollTop < 8
|
|
setIsPaused(!atTop)
|
|
}
|
|
|
|
function toggleFilter(severity: SeverityFilter) {
|
|
setActiveFilters((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(severity)) {
|
|
next.delete(severity)
|
|
} else {
|
|
next.add(severity)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const allSeverities: SeverityFilter[] = ['error', 'warning', 'success', 'running']
|
|
|
|
return (
|
|
<div className={`${styles.feed} ${className ?? ''}`}>
|
|
{/* Search + filter bar */}
|
|
<div className={styles.toolbar}>
|
|
<div className={styles.searchWrap}>
|
|
<input
|
|
type="text"
|
|
className={styles.searchInput}
|
|
placeholder="Search events…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
aria-label="Search events"
|
|
/>
|
|
{search && (
|
|
<button
|
|
type="button"
|
|
className={styles.searchClear}
|
|
onClick={() => setSearch('')}
|
|
aria-label="Clear search"
|
|
>
|
|
<XIcon size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className={styles.filters}>
|
|
<ButtonGroup
|
|
items={allSeverities.map((sev): ButtonGroupItem => ({
|
|
value: sev,
|
|
label: SEVERITY_LABELS[sev],
|
|
color: SEVERITY_COLORS[sev],
|
|
}))}
|
|
value={activeFilters as Set<string>}
|
|
onChange={(next) => setActiveFilters(next as Set<SeverityFilter>)}
|
|
/>
|
|
{activeFilters.size > 0 && (
|
|
<button
|
|
className={styles.clearBtn}
|
|
onClick={() => setActiveFilters(new Set())}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Event list */}
|
|
<div
|
|
ref={scrollRef}
|
|
className={styles.list}
|
|
onScroll={handleScroll}
|
|
aria-live="polite"
|
|
aria-label="Event feed"
|
|
>
|
|
{displayed.length === 0 ? (
|
|
<div className={styles.empty}>
|
|
{search ? 'No matching events' : 'No events'}
|
|
</div>
|
|
) : (
|
|
displayed.map((event) => (
|
|
<div key={event.id} className={styles.item}>
|
|
<div className={`${styles.icon} ${styles[`icon_${event.severity}`]}`}>
|
|
{event.icon ?? DEFAULT_ICONS[event.severity]}
|
|
</div>
|
|
<div className={styles.body}>
|
|
<div className={styles.message}>{event.message}</div>
|
|
<div className={styles.time}>
|
|
{formatRelativeTime(event.timestamp)} · {formatAbsoluteTime(event.timestamp)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Pause indicator */}
|
|
{isPaused && (
|
|
<button
|
|
className={styles.resumeBtn}
|
|
onClick={() => {
|
|
setIsPaused(false)
|
|
scrollToTop()
|
|
}}
|
|
>
|
|
↑ Scroll to latest
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|