WIP: Dashboard redesign — design system overhaul and component updates
Frontend redesign in progress: updated styles, layout, and components across all pages to align with new design system. Includes Jira API compliance specs, property tests, and load test script.
This commit is contained in:
662
docs/design-system-redesign/ui_kits/home/HomePrimitives.jsx
Normal file
662
docs/design-system-redesign/ui_kits/home/HomePrimitives.jsx
Normal file
@@ -0,0 +1,662 @@
|
||||
// HomePrimitives.jsx — primitives for the CVE Dashboard Home page kit.
|
||||
// Lifted directly from frontend/src/App.js (the home view), normalized to
|
||||
// match the same vocabulary the Reporting + Knowledge Base kits use.
|
||||
//
|
||||
// Exported on window.HOME for the assembly + docs files to consume.
|
||||
|
||||
const { useState: useHomeState } = React;
|
||||
|
||||
/* ── Tokens ──────────────────────────────────────────────────────
|
||||
Identical palette to Reporting + KB. Home adds purple (Archer)
|
||||
and teal (Ivanti) — both used as left-rail / title-glow colors
|
||||
on the right-side panel stack. */
|
||||
const H_COLORS = {
|
||||
sky: '#0EA5E9',
|
||||
skySoft: '#7DD3FC',
|
||||
green: '#10B981',
|
||||
amber: '#F59E0B',
|
||||
amberSoft: '#FCD34D',
|
||||
red: '#EF4444',
|
||||
redSoft: '#FCA5A5',
|
||||
purple: '#8B5CF6',
|
||||
teal: '#0D9488',
|
||||
};
|
||||
|
||||
/* Card chrome shared with the rest of the system. One chrome, every panel. */
|
||||
const CARD_BG = 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)';
|
||||
const CARD_BORDER = '1.5px solid rgba(14,165,233,0.12)';
|
||||
const CARD_BORDER_HOVER = '1.5px solid rgba(14,165,233,0.35)';
|
||||
|
||||
/* ── StatCard ────────────────────────────────────────────────────
|
||||
Top-of-page metric tile. Color-coded by tone — sky for neutral
|
||||
counts, amber for "needs attention", red for critical. Top edge
|
||||
has a soft horizontal glow line in the same color. */
|
||||
function StatCard({ label, value, tone = 'sky', mono = true }) {
|
||||
const c = H_COLORS[tone] || H_COLORS.sky;
|
||||
const isAccent = tone !== 'neutral';
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', overflow: 'hidden',
|
||||
background: CARD_BG,
|
||||
border: isAccent ? `2px solid ${c}` : CARD_BORDER,
|
||||
borderRadius: 8, padding: 16,
|
||||
boxShadow: isAccent
|
||||
? `0 4px 16px rgba(0,0,0,0.5), 0 0 20px ${withAlpha(c, 0.15)}, inset 0 1px 0 ${withAlpha(c, 0.15)}`
|
||||
: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, height: 2,
|
||||
background: `linear-gradient(90deg, transparent, ${c}, transparent)`,
|
||||
boxShadow: `0 0 8px ${withAlpha(c, 0.5)}`,
|
||||
}} />
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: mono ? 'var(--font-mono)' : 'var(--font-display)',
|
||||
fontSize: 24, fontWeight: 700, color: c,
|
||||
textShadow: isAccent ? `0 0 16px ${withAlpha(c, 0.4)}` : 'none',
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── HomeCard ────────────────────────────────────────────────────
|
||||
Same chrome as Reporting's KbCard but without a label slot —
|
||||
the home cards put their title inline above the body. Used as
|
||||
the wrapper for Quick Lookup, the filter row, and CVE rows. */
|
||||
function HomeCard({ children, padding = 24, hover = true, leftRail, style }) {
|
||||
const [h, setH] = useHomeState(false);
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => hover && setH(true)}
|
||||
onMouseLeave={() => setH(false)}
|
||||
style={{
|
||||
background: CARD_BG,
|
||||
border: h ? CARD_BORDER_HOVER : CARD_BORDER,
|
||||
borderLeft: leftRail ? `3px solid ${leftRail}` : (h ? CARD_BORDER_HOVER : CARD_BORDER).split(' ').slice(0).join(' '),
|
||||
borderRadius: 8,
|
||||
padding,
|
||||
transition: 'border-color 200ms ease, box-shadow 200ms ease',
|
||||
position: 'relative',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── CardTitle ───────────────────────────────────────────────────
|
||||
Mono uppercase, glow color matches the card's identity (sky for
|
||||
neutral, amber for tickets, purple for Archer, teal for Ivanti). */
|
||||
function CardTitle({ color = H_COLORS.sky, icon, children, action }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600,
|
||||
color, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textShadow: `0 0 12px ${withAlpha(color, 0.4)}`,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{icon && <HomeIcon name={icon} size={16} color={color} />}
|
||||
{children}
|
||||
</h3>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── HomeButton ──────────────────────────────────────────────────
|
||||
Wraps the four button variants the Home page uses, keeping the
|
||||
exact same tinted-fill / outlined treatment as the Reporting kit
|
||||
so all pages feel consistent. */
|
||||
function HomeButton({ variant = 'neutral', icon, children, size = 'md', ...rest }) {
|
||||
const [hover, setHover] = useHomeState(false);
|
||||
const v = {
|
||||
primary: { bg: hover ? 'rgba(16,185,129,0.18)' : 'rgba(16,185,129,0.10)', bd: H_COLORS.green, fg: H_COLORS.green },
|
||||
neutral: { bg: hover ? 'rgba(14,165,233,0.10)' : 'transparent', bd: 'rgba(14,165,233,0.5)', fg: H_COLORS.sky },
|
||||
subtle: { bg: hover ? 'rgba(14,165,233,0.16)' : 'rgba(14,165,233,0.08)', bd: 'rgba(14,165,233,0.30)', fg: H_COLORS.sky },
|
||||
danger: { bg: hover ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.10)', bd: 'rgba(239,68,68,0.5)', fg: H_COLORS.red },
|
||||
warning: { bg: hover ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.10)', bd: 'rgba(245,158,11,0.5)', fg: H_COLORS.amber },
|
||||
}[variant];
|
||||
const padX = size === 'sm' ? 10 : 14;
|
||||
const padY = size === 'sm' ? 4 : 8;
|
||||
const fs = size === 'sm' ? 11 : 12;
|
||||
return (
|
||||
<button
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: `${padY}px ${padX}px`, borderRadius: 6,
|
||||
background: v.bg, border: `1px solid ${v.bd}`, color: v.fg,
|
||||
fontFamily: 'var(--font-mono)', fontSize: fs, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
cursor: 'pointer', transition: 'all 160ms ease', whiteSpace: 'nowrap',
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{icon && <HomeIcon name={icon} size={fs + 2} color={v.fg} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── SeverityBadge ───────────────────────────────────────────────
|
||||
Strong tinted-fill badge used in CVE rows. Critical/High/Medium/Low. */
|
||||
function SeverityBadge({ level }) {
|
||||
const map = {
|
||||
Critical: { c: H_COLORS.red, text: H_COLORS.redSoft },
|
||||
High: { c: H_COLORS.amber, text: H_COLORS.amberSoft },
|
||||
Medium: { c: H_COLORS.sky, text: H_COLORS.skySoft },
|
||||
Low: { c: H_COLORS.green, text: '#6EE7B7' },
|
||||
}[level] || { c: H_COLORS.sky, text: H_COLORS.skySoft };
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
background: `linear-gradient(135deg, ${withAlpha(map.c, 0.25)}, ${withAlpha(map.c, 0.20)})`,
|
||||
border: `2px solid ${map.c}`, borderRadius: 6,
|
||||
padding: '4px 10px',
|
||||
color: map.text, fontWeight: 700, fontSize: 11,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
textShadow: `0 0 8px ${withAlpha(map.c, 0.5)}`,
|
||||
boxShadow: `0 0 16px ${withAlpha(map.c, 0.25)}, 0 4px 8px rgba(0,0,0,0.4)`,
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
|
||||
background: map.c, boxShadow: `0 0 8px ${map.c}`,
|
||||
}} />
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── StatusBadge ─────────────────────────────────────────────────
|
||||
Tone-coded text badge used for ticket statuses (Open / In Progress /
|
||||
Closed / Draft / Accepted). Smaller and lighter than SeverityBadge. */
|
||||
function StatusBadge({ tone = 'sky', children, size = 'md' }) {
|
||||
const c = H_COLORS[tone] || H_COLORS.sky;
|
||||
const fs = size === 'sm' ? 10 : 11;
|
||||
const pad = size === 'sm' ? '3px 7px' : '4px 9px';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
background: withAlpha(c, 0.18),
|
||||
border: `1px solid ${c}`, borderRadius: 4,
|
||||
padding: pad, color: c,
|
||||
fontFamily: 'var(--font-mono)', fontSize: fs, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
|
||||
background: c, boxShadow: `0 0 6px ${c}`,
|
||||
}} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── HomeInput / HomeSelect ──────────────────────────────────────
|
||||
The intel-input look: dark fill + sky border on focus. */
|
||||
function HomeInput({ icon, ...rest }) {
|
||||
const [focus, setFocus] = useHomeState(false);
|
||||
return (
|
||||
<div style={{ position: 'relative', flex: 1 }}>
|
||||
{icon && (
|
||||
<div style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: H_COLORS.sky }}>
|
||||
<HomeIcon name={icon} size={14} color={H_COLORS.sky} />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(15,23,42,0.85)',
|
||||
border: `1px solid ${focus ? H_COLORS.sky : 'rgba(14,165,233,0.25)'}`,
|
||||
borderRadius: 6,
|
||||
padding: icon ? '9px 12px 9px 34px' : '9px 12px',
|
||||
color: 'var(--fg-1)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 13,
|
||||
outline: 'none', transition: 'border-color 160ms ease',
|
||||
boxShadow: focus ? `0 0 0 3px ${withAlpha(H_COLORS.sky, 0.15)}` : 'none',
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeSelect({ value, onChange, options }) {
|
||||
return (
|
||||
<select value={value} onChange={onChange} style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(15,23,42,0.85)',
|
||||
border: '1px solid rgba(14,165,233,0.25)', borderRadius: 6,
|
||||
padding: '9px 12px', color: 'var(--fg-1)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 13,
|
||||
outline: 'none', appearance: 'none',
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230EA5E9' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat', backgroundPosition: 'right 12px center', paddingRight: 32,
|
||||
}}>
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({ icon, children }) {
|
||||
return (
|
||||
<label style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{icon && <HomeIcon name={icon} size={13} color="currentColor" />}
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── ResultBanner ────────────────────────────────────────────────
|
||||
Sub-card used in Quick Lookup to surface scan results.
|
||||
Tones: success (CVE addressed), warning (not found), error. */
|
||||
function ResultBanner({ tone = 'success', icon, title, children }) {
|
||||
const map = {
|
||||
success: { c: H_COLORS.green, bg: 'rgba(16,185,129,0.10)', bd: 'rgba(16,185,129,0.30)' },
|
||||
warning: { c: H_COLORS.amber, bg: 'rgba(245,158,11,0.10)', bd: 'rgba(245,158,11,0.30)' },
|
||||
error: { c: H_COLORS.red, bg: 'rgba(239,68,68,0.10)', bd: 'rgba(239,68,68,0.30)' },
|
||||
}[tone];
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
padding: 16, borderRadius: 6,
|
||||
background: map.bg, border: `1px solid ${map.bd}`,
|
||||
}}>
|
||||
<div style={{ color: map.c, marginTop: 1 }}>
|
||||
<HomeIcon name={icon || tone} size={18} color={map.c} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600,
|
||||
color: map.c, marginBottom: children ? 8 : 0,
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── BigStat ─────────────────────────────────────────────────────
|
||||
The centered "active count + label" shown at the top of each
|
||||
right-rail panel (Open Tickets · Archer · Ivanti). */
|
||||
function BigStat({ value, label, color = H_COLORS.sky }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', marginBottom: 12 }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 32, fontWeight: 700,
|
||||
color, textShadow: `0 0 16px ${withAlpha(color, 0.4)}`, lineHeight: 1,
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600,
|
||||
color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.12em',
|
||||
marginTop: 6,
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── MiniTicket ──────────────────────────────────────────────────
|
||||
Compact card shown inside the right-rail scrollable lists.
|
||||
Color-coded by category via the `tone` prop (amber/purple/teal). */
|
||||
function MiniTicket({ keyText, cveId, vendor, status, tone = 'amber', summary, onEdit, onDelete }) {
|
||||
const c = H_COLORS[tone] || H_COLORS.amber;
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.85), rgba(51,65,85,0.75))',
|
||||
border: `1px solid ${withAlpha(c, 0.25)}`, borderRadius: 6,
|
||||
padding: 10,
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.03)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, color: H_COLORS.sky }}>
|
||||
{keyText}
|
||||
</span>
|
||||
{(onEdit || onDelete) && (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{onEdit && <button onClick={onEdit} style={iconBtn(H_COLORS.amber)}><HomeIcon name="edit" size={11} color="currentColor" /></button>}
|
||||
{onDelete && <button onClick={onDelete} style={iconBtn(H_COLORS.red)}><HomeIcon name="trash" size={11} color="currentColor" /></button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-1)', marginBottom: 2 }}>{cveId}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-2)' }}>{vendor}</div>
|
||||
{summary && (
|
||||
<div style={{
|
||||
fontSize: 11, color: 'var(--fg-2)', marginTop: 4,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
fontFamily: 'var(--font-display)',
|
||||
}}>{summary}</div>
|
||||
)}
|
||||
{status && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<StatusBadge tone={tone} size="sm">{status}</StatusBadge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const iconBtn = (color) => ({
|
||||
background: 'transparent', border: 'none', color: 'var(--fg-2)',
|
||||
cursor: 'pointer', padding: 2, display: 'inline-flex', alignItems: 'center',
|
||||
transition: 'color 120ms ease',
|
||||
});
|
||||
|
||||
/* ── CVERow ──────────────────────────────────────────────────────
|
||||
The main "row" in the home feed. Collapsed = chevron · CVE-ID ·
|
||||
description · meta row (severity badge, vendor count, doc count,
|
||||
statuses). Expanded = full description + admin actions slot. */
|
||||
function CVERow({ cveId, severity, description, vendorCount, docCount, statuses, expanded, onToggle, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: CARD_BG, border: CARD_BORDER, borderRadius: 8,
|
||||
transition: 'border-color 200ms ease',
|
||||
}}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
width: '100%', textAlign: 'left',
|
||||
background: 'transparent', border: 'none',
|
||||
padding: 24, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
display: 'inline-block', transform: expanded ? 'rotate(0)' : 'rotate(-90deg)',
|
||||
transition: 'transform 200ms ease', color: H_COLORS.sky,
|
||||
}}>
|
||||
<HomeIcon name="chevron" size={18} color={H_COLORS.sky} />
|
||||
</span>
|
||||
<h3 style={{
|
||||
margin: 0, fontFamily: 'var(--font-mono)', fontSize: 22, fontWeight: 700,
|
||||
color: H_COLORS.sky, letterSpacing: '-0.01em',
|
||||
}}>{cveId}</h3>
|
||||
</div>
|
||||
<div style={{ marginLeft: 30 }}>
|
||||
<p style={{
|
||||
margin: '0 0 8px 0',
|
||||
color: 'var(--fg-1)', fontSize: 13, lineHeight: 1.5,
|
||||
fontFamily: 'var(--font-display)',
|
||||
display: '-webkit-box', WebkitLineClamp: expanded ? 'unset' : 1,
|
||||
WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{description}
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<SeverityBadge level={severity} />
|
||||
<span style={metaText}>{vendorCount} vendor{vendorCount !== 1 ? 's' : ''}</span>
|
||||
<span style={{ ...metaText, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<HomeIcon name="doc" size={11} color="currentColor" />
|
||||
{docCount} doc{docCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span style={metaText}>{statuses.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{expanded && children && (
|
||||
<div style={{ padding: '0 24px 24px', marginLeft: 30 }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metaText = {
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-2)',
|
||||
};
|
||||
|
||||
/* ── VendorEntry ─────────────────────────────────────────────────
|
||||
Sub-card inside an expanded CVE row, one per vendor that filed
|
||||
the CVE. Holds vendor name, severity, status, doc count, and
|
||||
inline action buttons. */
|
||||
function VendorEntry({ vendor, severity, status, docCount, children, onView, onEdit, onDelete }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(15,23,42,0.95) 0%, rgba(30,41,59,0.9) 100%)',
|
||||
border: '1.5px solid rgba(14,165,233,0.30)', borderRadius: 6,
|
||||
padding: 16, marginBottom: 12,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(14,165,233,0.08)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<h4 style={{ margin: 0, fontFamily: 'var(--font-display)', fontSize: 15, fontWeight: 600, color: 'var(--fg-1)' }}>{vendor}</h4>
|
||||
<SeverityBadge level={severity} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg-2)' }}>
|
||||
<span>Status: <strong style={{ color: 'var(--fg-1)', fontWeight: 500 }}>{status}</strong></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<HomeIcon name="doc" size={13} color="currentColor" />
|
||||
{docCount} doc{docCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
{onView && <HomeButton variant="neutral" icon="eye" size="sm" onClick={onView}>View</HomeButton>}
|
||||
{onEdit && <HomeButton variant="warning" icon="edit" size="sm" onClick={onEdit} />}
|
||||
{onDelete && <HomeButton variant="danger" icon="trash" size="sm" onClick={onDelete} />}
|
||||
</div>
|
||||
</div>
|
||||
{children && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid rgba(14,165,233,0.20)' }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── HomeIcon ────────────────────────────────────────────────────
|
||||
Inline SVGs covering every icon used on the home page so the kit
|
||||
has no external icon-font dependency. Keys mirror lucide-react names. */
|
||||
function HomeIcon({ name, size = 16, color = 'currentColor' }) {
|
||||
const p = {
|
||||
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke: color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
style: { display: 'inline-block', verticalAlign: 'middle' },
|
||||
};
|
||||
switch (name) {
|
||||
case 'search': return <svg {...p}><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>;
|
||||
case 'filter': return <svg {...p}><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>;
|
||||
case 'alert': return <svg {...p}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>;
|
||||
case 'check':
|
||||
case 'success': return <svg {...p}><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>;
|
||||
case 'warning': return <svg {...p}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>;
|
||||
case 'error':
|
||||
case 'x': return <svg {...p}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>;
|
||||
case 'shield': return <svg {...p}><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>;
|
||||
case 'activity': return <svg {...p}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>;
|
||||
case 'doc': return <svg {...p}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>;
|
||||
case 'eye': return <svg {...p}><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>;
|
||||
case 'edit': return <svg {...p}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
|
||||
case 'trash': return <svg {...p}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>;
|
||||
case 'plus': return <svg {...p}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>;
|
||||
case 'refresh': return <svg {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/></svg>;
|
||||
case 'chevron': return <svg {...p}><polyline points="6 9 12 15 18 9"/></svg>;
|
||||
case 'upload': return <svg {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
|
||||
case 'download': return <svg {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
|
||||
case 'calendar': return <svg {...p}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>;
|
||||
default: return <svg {...p}><circle cx="12" cy="12" r="10"/></svg>;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── CalendarMini ────────────────────────────────────────────────
|
||||
Minimal calendar surface for the right rail. Static — accepts a
|
||||
`today` index and an optional `markedDays` map for severity dots. */
|
||||
function CalendarMini({ month = 'April 2026', today = 26, markedDays = {} }) {
|
||||
const dows = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
// April 2026 starts on Wednesday — empty cells for S/M/T
|
||||
const startOffset = 3;
|
||||
const daysInMonth = 30;
|
||||
const cells = [...Array(startOffset).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1)];
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg-1)', fontWeight: 600 }}>{month}</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button style={navBtn}>‹</button>
|
||||
<button style={navBtn}>›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
||||
{dows.map((d, i) => (
|
||||
<div key={`dow-${i}`} style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-disabled)',
|
||||
textAlign: 'center', padding: '4px 0', fontWeight: 600,
|
||||
}}>{d}</div>
|
||||
))}
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`empty-${i}`} />;
|
||||
const mark = markedDays[day];
|
||||
const isToday = day === today;
|
||||
return (
|
||||
<button key={`day-${day}`} style={{
|
||||
position: 'relative',
|
||||
padding: '6px 0', borderRadius: 4,
|
||||
background: isToday ? withAlpha(H_COLORS.sky, 0.20) : 'transparent',
|
||||
border: isToday ? `1px solid ${H_COLORS.sky}` : '1px solid transparent',
|
||||
color: isToday ? H_COLORS.sky : 'var(--fg-1)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: isToday ? 700 : 500,
|
||||
cursor: 'pointer', transition: 'background 120ms ease',
|
||||
}}>
|
||||
{day}
|
||||
{mark && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: 2, left: '50%', transform: 'translateX(-50%)',
|
||||
width: 4, height: 4, borderRadius: '50%',
|
||||
background: H_COLORS[mark] || H_COLORS.amber,
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navBtn = {
|
||||
background: 'transparent', border: '1px solid rgba(14,165,233,0.25)',
|
||||
color: H_COLORS.sky, borderRadius: 4, width: 22, height: 22,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, cursor: 'pointer',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
|
||||
/* ── ArchiveSummary ──────────────────────────────────────────────
|
||||
The bar of state pills that lives at the top of the Ivanti card.
|
||||
Each pill shows an Ivanti workflow state + count, color-coded. */
|
||||
function ArchiveSummary({ items, activeFilter, onSelect }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{items.map(it => {
|
||||
const c = H_COLORS[it.tone] || H_COLORS.teal;
|
||||
const active = activeFilter === it.label;
|
||||
return (
|
||||
<button
|
||||
key={it.label}
|
||||
onClick={() => onSelect && onSelect(active ? null : it.label)}
|
||||
style={{
|
||||
flex: '1 1 60px',
|
||||
padding: '8px 10px',
|
||||
background: active ? withAlpha(c, 0.20) : withAlpha(c, 0.08),
|
||||
border: `1px solid ${active ? c : withAlpha(c, 0.30)}`,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: 16, fontWeight: 700, color: c,
|
||||
textShadow: active ? `0 0 8px ${withAlpha(c, 0.5)}` : 'none', lineHeight: 1,
|
||||
}}>{it.count}</div>
|
||||
<div style={{
|
||||
fontSize: 9, color: 'var(--fg-2)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em', marginTop: 4, fontWeight: 600,
|
||||
}}>{it.label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── ScrollList ──────────────────────────────────────────────────
|
||||
Generic max-height scroll wrapper for the right-rail panels. */
|
||||
function ScrollList({ maxHeight = 300, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
maxHeight, overflowY: 'auto',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
paddingRight: 4,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── EmptyState ──────────────────────────────────────────────────
|
||||
Center-aligned check-circle + caption, used inside ScrollList
|
||||
when a panel has no items. */
|
||||
function EmptyState({ icon = 'check', tone = 'green', children }) {
|
||||
const c = H_COLORS[tone] || H_COLORS.green;
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||
<div style={{ display: 'inline-flex', marginBottom: 8 }}>
|
||||
<HomeIcon name={icon} size={32} color={c} />
|
||||
</div>
|
||||
<p style={{
|
||||
margin: 0, fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
color: 'var(--fg-2)', fontStyle: 'italic',
|
||||
}}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────── */
|
||||
function withAlpha(hex, a) {
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
window.HOME = {
|
||||
COLORS: H_COLORS,
|
||||
StatCard, HomeCard, CardTitle, HomeButton, SeverityBadge, StatusBadge,
|
||||
HomeInput, HomeSelect, FieldLabel, ResultBanner,
|
||||
BigStat, MiniTicket, CVERow, VendorEntry,
|
||||
HomeIcon, CalendarMini, ArchiveSummary, ScrollList, EmptyState,
|
||||
withAlpha,
|
||||
};
|
||||
Reference in New Issue
Block a user