- Help page now scrolls (it sat under a clipped section with no overflow handling). - Connected Integrations on Glance shows 5 per row in a scrollable area with the transparent ghost scrollbar, instead of growing the card unbounded. - System Status KPI ring is now a thicker, vertically centered, multi-segment donut broken down by integration type, each type colored consistently from a shared first-come-first-served palette (src/lib/integrationColors.ts) so e.g. whichever type connects first always gets the same color everywhere it's used. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hu9pZvJY4BgmcQeAw2ugk
101 lines
3.1 KiB
TypeScript
101 lines
3.1 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
|
|
interface ProgressRingProps {
|
|
percentage: number
|
|
size?: number
|
|
strokeWidth?: number
|
|
}
|
|
|
|
export default function ProgressRing({ percentage, size = 56, strokeWidth = 4 }: ProgressRingProps) {
|
|
const [animatedPercentage, setAnimatedPercentage] = useState(0)
|
|
const radius = (size - strokeWidth) / 2
|
|
const circumference = 2 * Math.PI * radius
|
|
const offset = circumference - (animatedPercentage / 100) * circumference
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setAnimatedPercentage(percentage), 100)
|
|
return () => clearTimeout(timer)
|
|
}, [percentage])
|
|
|
|
return (
|
|
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
|
<svg width={size} height={size} className="-rotate-90">
|
|
{/* Background circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="#1E2025"
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
{/* Progress circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="#C8A434"
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={offset}
|
|
strokeLinecap="round"
|
|
className="transition-all duration-1000 ease-out"
|
|
/>
|
|
</svg>
|
|
<span className="absolute text-xs font-bold text-text-primary">
|
|
{percentage}%
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface RingSegment {
|
|
color: string
|
|
value: number
|
|
}
|
|
|
|
interface MultiSegmentRingProps {
|
|
segments: RingSegment[]
|
|
total: number
|
|
size?: number
|
|
strokeWidth?: number
|
|
centerLabel?: string
|
|
}
|
|
|
|
export function MultiSegmentRing({ segments, total, size = 64, strokeWidth = 10, centerLabel }: MultiSegmentRingProps) {
|
|
const radius = (size - strokeWidth) / 2
|
|
const circumference = 2 * Math.PI * radius
|
|
|
|
let cumulative = 0
|
|
const drawn = segments.filter((s) => s.value > 0)
|
|
|
|
return (
|
|
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
|
<svg width={size} height={size} className="-rotate-90">
|
|
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="#1E2025" strokeWidth={strokeWidth} />
|
|
{total > 0 &&
|
|
drawn.map((seg, i) => {
|
|
const segLen = (seg.value / total) * circumference
|
|
const offset = circumference - cumulative
|
|
cumulative += segLen
|
|
return (
|
|
<circle
|
|
key={i}
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke={seg.color}
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={`${segLen} ${circumference - segLen}`}
|
|
strokeDashoffset={offset}
|
|
className="transition-all duration-1000 ease-out"
|
|
/>
|
|
)
|
|
})}
|
|
</svg>
|
|
{centerLabel && <span className="absolute text-sm font-bold text-text-primary">{centerLabel}</span>}
|
|
</div>
|
|
)
|
|
}
|