omnieye-theme-studio 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/launch.json +11 -0
- package/.github/workflows/ci.yml +88 -0
- package/.github/workflows/promote.yml +48 -0
- package/Dockerfile +20 -0
- package/README.md +52 -0
- package/dist/assets/index-B0UK7Lt_.js +200 -0
- package/dist/index.html +18 -0
- package/index.html +18 -0
- package/nginx.conf +15 -0
- package/package.json +26 -0
- package/src/App.tsx +79 -0
- package/src/chrome/Footer.tsx +21 -0
- package/src/chrome/IconRail.tsx +49 -0
- package/src/chrome/StudioSubHeader.tsx +14 -0
- package/src/chrome/TopBar.tsx +35 -0
- package/src/main.tsx +23 -0
- package/src/panels/CardsPanel.tsx +75 -0
- package/src/panels/ColourPanel.tsx +175 -0
- package/src/panels/LoginPanel.tsx +62 -0
- package/src/panels/NotificationsPanel.tsx +37 -0
- package/src/panels/SurfacePanel.tsx +66 -0
- package/src/panels/TablesPanel.tsx +57 -0
- package/src/panels/ThemeManagement.tsx +178 -0
- package/src/panels/TypographyPanel.tsx +154 -0
- package/src/preview/CamerasPreview.tsx +140 -0
- package/src/preview/ComponentsPreview.tsx +146 -0
- package/src/preview/DashboardPreview.tsx +138 -0
- package/src/preview/LoginPreview.tsx +111 -0
- package/src/preview/OperationsPreview.tsx +103 -0
- package/src/preview/OrdersPreview.tsx +108 -0
- package/src/preview/PreviewPane.tsx +111 -0
- package/src/preview/ReportsPreview.tsx +140 -0
- package/src/preview/ToastLayer.tsx +34 -0
- package/src/preview/useFitCount.ts +68 -0
- package/src/shared/ChipGroup.tsx +52 -0
- package/src/shared/CropModal.tsx +73 -0
- package/src/shared/InfoBadge.tsx +13 -0
- package/src/shared/SectionAccordion.tsx +89 -0
- package/src/shared/StudioSlider.tsx +103 -0
- package/src/shared/SwatchPicker.tsx +44 -0
- package/src/shared/TabStrip.tsx +51 -0
- package/src/store/useStudioUi.ts +47 -0
- package/src/store/useThemeStore.ts +103 -0
- package/src/studio/StudioRail.tsx +167 -0
- package/src/theme/buildMuiTheme.ts +53 -0
- package/src/theme/color.ts +68 -0
- package/src/theme/presets.ts +17 -0
- package/src/theme/settings.ts +158 -0
- package/src/theme/studioTokens.ts +24 -0
- package/src/theme/tokens.ts +243 -0
- package/src/theme/useTokens.ts +10 -0
- package/tsconfig.json +23 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +7 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useTokens } from '../theme/useTokens';
|
|
2
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
3
|
+
import { rgbaOf } from '../theme/color';
|
|
4
|
+
import { useFitCount } from './useFitCount';
|
|
5
|
+
|
|
6
|
+
// Operations screen (modules) — 1:1 port of the HTML app's operations center.
|
|
7
|
+
const NOTIFS = [
|
|
8
|
+
{ ic: '!', label: 'Camera 04 motion detected — Loading Bay', time: 'just now', sev: 'error', unread: true },
|
|
9
|
+
{ ic: '✓', label: 'Stream reconnected — North Gate feed', time: '4m ago', sev: 'success', unread: true },
|
|
10
|
+
{ ic: 'i', label: 'Access request — J. Okoro (Operator)', time: '22m ago', sev: 'info', unread: false },
|
|
11
|
+
{ ic: '!', label: 'Report archive storage 82% full', time: '1h ago', sev: 'warning', unread: false },
|
|
12
|
+
{ ic: '✓', label: 'Backup completed — 12,480 clips archived', time: '2h ago', sev: 'success', unread: false },
|
|
13
|
+
{ ic: 'i', label: 'Firmware update available — 3 devices', time: '3h ago', sev: 'info', unread: false },
|
|
14
|
+
{ ic: '!', label: 'Camera 02 lens obstruction detected', time: '5h ago', sev: 'error', unread: false },
|
|
15
|
+
{ ic: '✓', label: 'Nightly health check passed — all nodes', time: '6h ago', sev: 'success', unread: false },
|
|
16
|
+
{ ic: 'i', label: 'New operator invited — M. Fernandez', time: '7h ago', sev: 'info', unread: false },
|
|
17
|
+
{ ic: '!', label: 'Bandwidth spike on West Wing uplink', time: '8h ago', sev: 'warning', unread: false },
|
|
18
|
+
{ ic: '✓', label: 'Retention policy applied — 30 day window', time: '9h ago', sev: 'success', unread: false },
|
|
19
|
+
{ ic: 'i', label: 'Scheduled maintenance — Sat 02:00 UTC', time: '11h ago', sev: 'info', unread: false },
|
|
20
|
+
{ ic: '!', label: 'Motion zone breach — Perimeter Gate 3', time: '12h ago', sev: 'error', unread: false },
|
|
21
|
+
{ ic: '✓', label: 'Clip export finished — Incident #4821', time: '14h ago', sev: 'success', unread: false },
|
|
22
|
+
{ ic: 'i', label: 'Analytics model updated — v2.4.1', time: '16h ago', sev: 'info', unread: false },
|
|
23
|
+
{ ic: '!', label: 'Disk latency elevated — NVR-02', time: '18h ago', sev: 'warning', unread: false },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function OperationsPreview() {
|
|
27
|
+
const T = useTokens();
|
|
28
|
+
const s = useThemeStore((st) => st.settings);
|
|
29
|
+
const { t, rad, radSm, pad, padSm, gap, screenPad, tH1, tTitle, wH1: wH, headFam, headLH, accentBg, btnText, tBodySm, tCaption, tOverline, bwEmph, bodyWeightEff, bodyLH, errSoft, errTxt, pMain, mediaBg, overlaySurface, overlayBorder, onOverlay, sevMap } = T;
|
|
30
|
+
const headFamCss = headFam + ',sans-serif';
|
|
31
|
+
const bodyFamCss = s.fontFamily + ',sans-serif';
|
|
32
|
+
const hc1 = T.hc1 || t.textPrimary, hc3 = T.hc3 || t.textPrimary;
|
|
33
|
+
const cardBorderCss = T.cardBorder === 'none' ? 'none' : T.cardBorder;
|
|
34
|
+
|
|
35
|
+
const [notifRef, notifCount] = useFitCount(7, 4, 16);
|
|
36
|
+
const notifs = NOTIFS.slice(0, notifCount).map((n) => ({ ...n, ...sevMap[n.sev], rowBg: n.unread ? rgbaOf(s.primaryHex, T.isDark ? 0.12 : 0.05) : 'transparent' }));
|
|
37
|
+
const unreadCount = notifs.filter((n) => n.unread).length;
|
|
38
|
+
|
|
39
|
+
const card: React.CSSProperties = { display: 'flex', flexDirection: 'column', minHeight: 0, background: t.paper, border: cardBorderCss, borderRadius: rad, boxShadow: T.cardShadow, overflow: 'hidden' };
|
|
40
|
+
const cardHead: React.CSSProperties = { padding: `${padSm}px ${pad}px`, background: T.cardHdrBg, borderBottom: T.cardHdrBorder, boxShadow: T.cardHdrAccent, display: 'flex', alignItems: 'center', justifyContent: 'space-between' };
|
|
41
|
+
const secTitle: React.CSSProperties = { fontSize: tTitle, lineHeight: headLH, fontWeight: wH, color: hc3, fontFamily: headFamCss };
|
|
42
|
+
const zBtn: React.CSSProperties = { height: 30, borderRadius: radSm, border: 'none', background: 'rgba(255,255,255,.14)', color: onOverlay, fontSize: tCaption, fontWeight: bwEmph, cursor: 'pointer', fontFamily: 'inherit' };
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div style={{ display: 'flex', flexDirection: 'column', background: t.paper, border: cardBorderCss, borderRadius: rad, boxShadow: T.cardShadow, overflow: 'hidden', fontFamily: bodyFamCss, fontWeight: bodyWeightEff, color: t.textPrimary }}>
|
|
46
|
+
{/* App header */}
|
|
47
|
+
<div style={{ padding: pad, background: T.cardHdrBg, borderBottom: T.cardHdrBorder, boxShadow: T.cardHdrAccent }}>
|
|
48
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
49
|
+
<div style={{ fontSize: tH1, lineHeight: headLH, fontWeight: wH, color: hc1, letterSpacing: '-.3px', fontFamily: headFamCss }}>Operations Center</div>
|
|
50
|
+
<span title="Live map, camera feeds, alerts & access — every module themed end to end" style={{ width: 16, height: 16, borderRadius: '50%', border: '1.2px solid ' + t.textSecondary, color: t.textSecondary, fontSize: 9.5, fontWeight: bwEmph, fontStyle: 'italic', fontFamily: 'Georgia,serif', display: 'grid', placeItems: 'center', flexShrink: 0, cursor: 'help' }}>i</span>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{/* Body */}
|
|
55
|
+
<div style={{ padding: screenPad, background: t.bgDefault, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
56
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap, flex: 1, minHeight: 0 }}>
|
|
57
|
+
{/* Live Map */}
|
|
58
|
+
<div style={card}>
|
|
59
|
+
<div style={cardHead}>
|
|
60
|
+
<span style={secTitle}>Live Map · Street View</span>
|
|
61
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: tCaption, fontWeight: bwEmph, color: errTxt, background: errSoft, padding: '3px 10px', borderRadius: 999 }}>
|
|
62
|
+
<span style={{ width: 7, height: 7, borderRadius: '50%', background: s.errorHex }} />LIVE
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div style={{ position: 'relative', flex: 1, minHeight: 320, background: mediaBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
66
|
+
<span style={{ fontFamily: 'monospace', fontSize: tCaption, color: 'rgba(255,255,255,.42)', letterSpacing: 1.5 }}>◍ GOOGLE STREET VIEW / EARTH EMBED</span>
|
|
67
|
+
<div style={{ position: 'absolute', top: 12, left: 12, display: 'flex', alignItems: 'center', gap: 7, background: overlaySurface, border: '1px solid ' + overlayBorder, borderRadius: 999, padding: '6px 12px' }}>
|
|
68
|
+
<span style={{ color: onOverlay, fontSize: tCaption, fontWeight: bwEmph }}>◎ 40.7128, -74.0060 · Manhattan</span>
|
|
69
|
+
</div>
|
|
70
|
+
<div style={{ position: 'absolute', bottom: 12, left: '50%', transform: 'translateX(-50%)', display: 'flex', alignItems: 'center', gap: 4, background: overlaySurface, border: '1px solid ' + overlayBorder, borderRadius: radSm, padding: 5 }}>
|
|
71
|
+
<button style={{ width: 30, height: 30, borderRadius: radSm, border: 'none', background: accentBg, color: btnText, fontSize: 16, fontWeight: bwEmph, cursor: 'pointer' }}>+</button>
|
|
72
|
+
<button style={{ width: 30, height: 30, borderRadius: radSm, border: 'none', background: 'rgba(255,255,255,.14)', color: onOverlay, fontSize: 16, fontWeight: bwEmph, cursor: 'pointer' }}>−</button>
|
|
73
|
+
<div style={{ width: 1, height: 18, background: overlayBorder, margin: '0 3px' }} />
|
|
74
|
+
<button style={{ ...zBtn, padding: '0 12px' }}>Layers</button>
|
|
75
|
+
<button style={{ ...zBtn, padding: '0 12px' }}>◎ Locate</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{/* Notifications */}
|
|
81
|
+
<div style={card}>
|
|
82
|
+
<div style={cardHead}>
|
|
83
|
+
<span style={secTitle}>Notifications</span>
|
|
84
|
+
<span style={{ fontSize: tOverline, fontWeight: bwEmph, color: t.pContrast, background: pMain, padding: '2px 9px', borderRadius: 999 }}>{unreadCount} NEW</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div ref={notifRef} style={{ padding: padSm, display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minHeight: 0 }}>
|
|
87
|
+
{notifs.map((n, i) => (
|
|
88
|
+
<div key={i} style={{ display: 'flex', gap: 11, alignItems: 'flex-start', padding: '9px 8px', borderRadius: radSm, background: n.rowBg }}>
|
|
89
|
+
<div style={{ width: 26, height: 26, borderRadius: radSm, background: n.cb, color: n.cc, display: 'grid', placeItems: 'center', flexShrink: 0, fontSize: 13, fontWeight: bwEmph }}>{n.ic}</div>
|
|
90
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
91
|
+
<div style={{ fontSize: tBodySm, color: t.textPrimary, fontWeight: bodyWeightEff, lineHeight: bodyLH }}>{n.label}</div>
|
|
92
|
+
<div style={{ fontSize: tCaption, color: t.textSecondary, marginTop: 2 }}>{n.time}</div>
|
|
93
|
+
</div>
|
|
94
|
+
{n.unread && <div style={{ width: 8, height: 8, borderRadius: '50%', background: pMain, flexShrink: 0, marginTop: 6 }} />}
|
|
95
|
+
</div>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useTokens } from '../theme/useTokens';
|
|
2
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
3
|
+
import { useFitRows } from './useFitCount';
|
|
4
|
+
|
|
5
|
+
// Orders screen — 1:1 port of the HTML app's orders table (rows fill the card via tblFit).
|
|
6
|
+
const ALL_ROWS = [
|
|
7
|
+
{ id: 'ORD-1042', cust: 'Acme Corporation', status: 'Delivered', amt: '$12,400', date: 'Jun 18' },
|
|
8
|
+
{ id: 'ORD-1041', cust: 'Globex Ltd', status: 'Processing', amt: '$8,750', date: 'Jun 17' },
|
|
9
|
+
{ id: 'ORD-1040', cust: 'Initech', status: 'Pending', amt: '$5,200', date: 'Jun 16' },
|
|
10
|
+
{ id: 'ORD-1039', cust: 'Umbrella Co', status: 'Shipped', amt: '$22,100', date: 'Jun 15' },
|
|
11
|
+
{ id: 'ORD-1038', cust: 'Waystar Royco', status: 'Delivered', amt: '$9,800', date: 'Jun 14' },
|
|
12
|
+
{ id: 'ORD-1037', cust: 'Stark Industries', status: 'Processing', amt: '$31,500', date: 'Jun 13' },
|
|
13
|
+
{ id: 'ORD-1036', cust: 'Wayne Enterprises', status: 'Delivered', amt: '$18,240', date: 'Jun 12' },
|
|
14
|
+
{ id: 'ORD-1035', cust: 'Soylent Corp', status: 'Pending', amt: '$4,120', date: 'Jun 11' },
|
|
15
|
+
{ id: 'ORD-1034', cust: 'Hooli Inc', status: 'Shipped', amt: '$27,900', date: 'Jun 10' },
|
|
16
|
+
{ id: 'ORD-1033', cust: 'Cyberdyne Systems', status: 'Delivered', amt: '$14,650', date: 'Jun 09' },
|
|
17
|
+
{ id: 'ORD-1032', cust: 'Massive Dynamic', status: 'Processing', amt: '$10,300', date: 'Jun 08' },
|
|
18
|
+
{ id: 'ORD-1031', cust: 'Tyrell Corp', status: 'Delivered', amt: '$16,780', date: 'Jun 07' },
|
|
19
|
+
{ id: 'ORD-1030', cust: 'Oscorp', status: 'Shipped', amt: '$7,940', date: 'Jun 06' },
|
|
20
|
+
{ id: 'ORD-1029', cust: 'Nakatomi Trading', status: 'Pending', amt: '$3,560', date: 'Jun 05' },
|
|
21
|
+
{ id: 'ORD-1028', cust: 'Gekko & Co', status: 'Delivered', amt: '$25,300', date: 'Jun 04' },
|
|
22
|
+
{ id: 'ORD-1027', cust: 'Bluth Company', status: 'Processing', amt: '$6,410', date: 'Jun 03' },
|
|
23
|
+
];
|
|
24
|
+
const COLS = '100px 1fr 120px 100px 80px 70px';
|
|
25
|
+
|
|
26
|
+
export function OrdersPreview() {
|
|
27
|
+
const T = useTokens();
|
|
28
|
+
const s = useThemeStore((st) => st.settings);
|
|
29
|
+
const { t, rad, radSm, pad, padSm, screenPad, rowPy, tH1, wH1: wH, headFam, headLH, accentBg, btnText, btnShadow, tBody, tBodySm, tCaption, tOverline, bwEmph, btnCase, bodyWeightEff, pMain, table } = T;
|
|
30
|
+
const headFamCss = headFam + ',sans-serif';
|
|
31
|
+
const bodyFamCss = s.fontFamily + ',sans-serif';
|
|
32
|
+
const hc1 = T.hc1 || t.textPrimary;
|
|
33
|
+
|
|
34
|
+
const [headRef, bodyRef, rowCount] = useFitRows(12, 3, 16);
|
|
35
|
+
const rows = ALL_ROWS.slice(0, rowCount).map((x, i) => ({
|
|
36
|
+
...x,
|
|
37
|
+
...(table.statusMap[x.status] || { sb: t.divider, sc: t.textSecondary }),
|
|
38
|
+
rowBg: s.tableStripe && i % 2 === 1 ? table.stripeBg : 'transparent',
|
|
39
|
+
}));
|
|
40
|
+
const cardBorderCss = T.cardBorder === 'none' ? 'none' : T.cardBorder;
|
|
41
|
+
|
|
42
|
+
const headCell = (_label: string): React.CSSProperties => ({
|
|
43
|
+
fontSize: tOverline, fontWeight: bwEmph, color: t.textSecondary, textTransform: 'uppercase', letterSpacing: '.5px',
|
|
44
|
+
borderRight: table.colBorder, alignSelf: 'stretch', display: 'flex', alignItems: table.cellAI, justifyContent: table.cellJC, padding: `${table.rowPy} ${table.cellPx}`,
|
|
45
|
+
});
|
|
46
|
+
const cellBase: React.CSSProperties = { alignSelf: 'stretch', display: 'flex', alignItems: table.cellAI, justifyContent: table.cellJC, padding: `${table.rowPy} ${table.cellPx}` };
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div style={{ display: 'flex', flexDirection: 'column', background: t.paper, border: cardBorderCss, borderRadius: rad, boxShadow: T.cardShadow, overflow: 'hidden', fontFamily: bodyFamCss, fontWeight: bodyWeightEff, color: t.textPrimary }}>
|
|
50
|
+
{/* App header */}
|
|
51
|
+
<div style={{ padding: pad, background: T.cardHdrBg, borderBottom: T.cardHdrBorder, boxShadow: T.cardHdrAccent, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 14 }}>
|
|
52
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
53
|
+
<div style={{ fontSize: tH1, lineHeight: headLH, fontWeight: wH, color: hc1, letterSpacing: '-.3px', fontFamily: headFamCss }}>Orders</div>
|
|
54
|
+
<span title="1,284 orders · June 2026" style={{ width: 16, height: 16, borderRadius: '50%', border: '1.2px solid ' + t.textSecondary, color: t.textSecondary, fontSize: 9.5, fontWeight: bwEmph, fontStyle: 'italic', fontFamily: 'Georgia,serif', display: 'grid', placeItems: 'center', flexShrink: 0, cursor: 'help' }}>i</span>
|
|
55
|
+
</div>
|
|
56
|
+
<button style={{ padding: '10px 20px', borderRadius: radSm, border: 'none', background: accentBg, color: btnText, fontSize: tBodySm, fontWeight: bwEmph, textTransform: btnCase as 'uppercase' | 'none', letterSpacing: '.4px', boxShadow: btnShadow, whiteSpace: 'nowrap', cursor: 'pointer' }}>+ New Order</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{/* Body */}
|
|
60
|
+
<div style={{ padding: screenPad, background: t.bgDefault, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
61
|
+
<div style={{ display: 'flex', flexDirection: 'column', background: t.paper, border: cardBorderCss, borderRadius: rad, boxShadow: T.cardShadow, overflow: 'hidden', flex: 1, minHeight: 0 }}>
|
|
62
|
+
{/* Toolbar */}
|
|
63
|
+
<div style={{ padding: `${padSm}px ${pad}px`, background: T.cardHdrBg, borderBottom: T.cardHdrBorder, boxShadow: T.cardHdrAccent, display: 'flex', gap: 9, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
64
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: t.paper, border: '1.5px solid ' + t.divider, borderRadius: 999, padding: '8px 15px', flex: 1, minWidth: 170 }}>
|
|
65
|
+
<span style={{ color: t.textSecondary, fontSize: 13 }}>⌕</span>
|
|
66
|
+
<span style={{ fontSize: tBodySm, color: t.textSecondary }}>Search orders…</span>
|
|
67
|
+
</div>
|
|
68
|
+
{table.filters.map((tf) => (
|
|
69
|
+
<button key={tf.label} style={{ padding: '7px 14px', borderRadius: 999, border: '1px solid ' + tf.brd, background: tf.bg, color: tf.c, fontSize: tCaption, fontWeight: bwEmph, whiteSpace: 'nowrap', cursor: 'pointer' }}>{tf.label}</button>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Head */}
|
|
74
|
+
<div ref={headRef} style={{ display: 'grid', gridTemplateColumns: COLS, padding: `0 ${pad}px`, borderBottom: table.rowBorder, background: table.headBg }}>
|
|
75
|
+
{['Order ID', 'Customer', 'Status', 'Amount', 'Date'].map((h) => <div key={h} style={headCell(h)}>{h}</div>)}
|
|
76
|
+
<div style={{ alignSelf: 'stretch', padding: `${table.rowPy} 0` }} />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Rows */}
|
|
80
|
+
<div ref={bodyRef} style={{ position: 'relative', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
81
|
+
<div style={{ position: 'absolute', inset: 0, padding: `0 ${pad}px`, display: 'grid', gridTemplateColumns: COLS, pointerEvents: 'none', zIndex: 2 }}>
|
|
82
|
+
<div style={{ borderRight: table.colBorder }} /><div style={{ borderRight: table.colBorder }} /><div style={{ borderRight: table.colBorder }} /><div style={{ borderRight: table.colBorder }} /><div style={{ borderRight: table.colBorder }} /><div />
|
|
83
|
+
</div>
|
|
84
|
+
{rows.map((row) => (
|
|
85
|
+
<div key={row.id} style={{ display: 'grid', gridTemplateColumns: COLS, padding: `0 ${pad}px`, borderBottom: table.rowBorder, alignItems: 'stretch', background: row.rowBg }}>
|
|
86
|
+
<div style={{ ...cellBase, fontSize: tBodySm, fontWeight: bwEmph, color: pMain, fontFamily: bodyFamCss }}>{row.id}</div>
|
|
87
|
+
<div style={{ ...cellBase, fontSize: tBody, color: t.textPrimary, fontWeight: bwEmph }}>{row.cust}</div>
|
|
88
|
+
<div style={cellBase}><span style={{ padding: '4px 11px', borderRadius: 999, fontSize: tCaption, fontWeight: bwEmph, background: row.sb, color: row.sc, whiteSpace: 'nowrap' }}>{row.status}</span></div>
|
|
89
|
+
<div style={{ ...cellBase, fontSize: tBody, color: t.textPrimary, fontWeight: bwEmph }}>{row.amt}</div>
|
|
90
|
+
<div style={{ ...cellBase, fontSize: tBodySm, color: t.textSecondary }}>{row.date}</div>
|
|
91
|
+
<div style={cellBase}><button style={{ fontSize: tCaption, color: pMain, background: t.pContainer, border: 'none', fontWeight: bwEmph, padding: '5px 11px', borderRadius: radSm, cursor: 'pointer' }}>View</button></div>
|
|
92
|
+
</div>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{/* Pagination */}
|
|
97
|
+
<div style={{ padding: `${rowPy}px ${pad}px`, display: 'flex', alignItems: 'center', gap: 7, borderTop: '1px solid ' + t.divider }}>
|
|
98
|
+
<span style={{ fontSize: tBodySm, color: t.textSecondary, flex: 1 }}>Showing 1–{rows.length} of 1,284</span>
|
|
99
|
+
<button style={{ width: 32, height: 32, borderRadius: radSm, border: '1px solid ' + t.outline, background: 'transparent', color: t.textSecondary, cursor: 'pointer' }}>‹</button>
|
|
100
|
+
<button style={{ width: 32, height: 32, borderRadius: radSm, border: 'none', background: accentBg, color: btnText, fontWeight: bwEmph, cursor: 'pointer' }}>1</button>
|
|
101
|
+
<button style={{ width: 32, height: 32, borderRadius: radSm, border: '1px solid ' + t.outline, background: 'transparent', color: t.textSecondary, cursor: 'pointer' }}>2</button>
|
|
102
|
+
<button style={{ width: 32, height: 32, borderRadius: radSm, border: '1px solid ' + t.outline, background: 'transparent', color: t.textSecondary, cursor: 'pointer' }}>›</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Box, Typography, Select, MenuItem } from '@mui/material';
|
|
3
|
+
import DesktopWindowsOutlinedIcon from '@mui/icons-material/DesktopWindowsOutlined';
|
|
4
|
+
import { DashboardPreview } from './DashboardPreview';
|
|
5
|
+
import { OrdersPreview } from './OrdersPreview';
|
|
6
|
+
import { ComponentsPreview } from './ComponentsPreview';
|
|
7
|
+
import { CamerasPreview } from './CamerasPreview';
|
|
8
|
+
import { OperationsPreview } from './OperationsPreview';
|
|
9
|
+
import { ReportsPreview } from './ReportsPreview';
|
|
10
|
+
import { LoginPreview } from './LoginPreview';
|
|
11
|
+
import { ToastLayer } from './ToastLayer';
|
|
12
|
+
import { useStudioUi, SCREENS } from '../store/useStudioUi';
|
|
13
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
14
|
+
|
|
15
|
+
const ZOOMS = [75, 90, 100, 125];
|
|
16
|
+
|
|
17
|
+
export function PreviewPane() {
|
|
18
|
+
const screen = useStudioUi((s) => s.previewScreen);
|
|
19
|
+
// Edge gap — space between the preview frame and the screen area (0 = flush). Operations
|
|
20
|
+
// (the full-bleed map screen) is always flush, matching the HTML's screenAreaPad.
|
|
21
|
+
const edgePadPx = useThemeStore((s) => s.settings.edgePadPx) || 0;
|
|
22
|
+
const edgePad = screen === 'operations' ? 0 : edgePadPx;
|
|
23
|
+
const [zoom, setZoom] = useState<'fit' | number>('fit');
|
|
24
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const innerRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
const [fitPct, setFitPct] = useState(100);
|
|
27
|
+
|
|
28
|
+
// Fit/zoom — ported from the HTML app's fitPreview: the content is responsive (fills the pane
|
|
29
|
+
// width), and "Fit" scales it down to fit the pane's width AND height, capped at 100% (never
|
|
30
|
+
// scales up), snapping to exactly 100% when the natural scale is ≥ 0.985. Fixed % just applies
|
|
31
|
+
// that zoom. `zoom` is a non-standard CSS property but is what the original uses.
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
const outer = scrollRef.current, inner = innerRef.current;
|
|
34
|
+
if (!outer || !inner) return;
|
|
35
|
+
const run = () => {
|
|
36
|
+
const cs = getComputedStyle(outer);
|
|
37
|
+
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
|
|
38
|
+
if (zoom !== 'fit') {
|
|
39
|
+
const z = zoom / 100;
|
|
40
|
+
inner.style.zoom = String(z);
|
|
41
|
+
const availH = outer.clientHeight - padY;
|
|
42
|
+
inner.style.minHeight = (availH > 0 ? Math.floor(availH / z) : 0) + 'px';
|
|
43
|
+
setFitPct(zoom);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Measure the natural (unscaled) content size, then scale to fit.
|
|
47
|
+
inner.style.zoom = '1';
|
|
48
|
+
inner.style.minHeight = '0px';
|
|
49
|
+
void inner.offsetHeight;
|
|
50
|
+
const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
|
|
51
|
+
const availW = outer.clientWidth - padX, availH = outer.clientHeight - padY;
|
|
52
|
+
const needW = inner.scrollWidth, needH = inner.scrollHeight;
|
|
53
|
+
if (needW <= 0 || needH <= 0 || availW <= 0 || availH <= 0) return;
|
|
54
|
+
const scale = Math.min(availW / needW, availH / needH, 1);
|
|
55
|
+
const z = scale < 0.985 ? Math.max(0.36, scale) : 1;
|
|
56
|
+
inner.style.zoom = String(z);
|
|
57
|
+
// Fill height with content up to the *width* scale only. needW (content width) is independent
|
|
58
|
+
// of the dynamic row counts, so this is a stable target — unlike availH/z, which feeds back
|
|
59
|
+
// through the counts and creates multiple fixed points (e.g. a fixed-zoom → Fit round-trip
|
|
60
|
+
// getting stuck at 67%). When width fits, this is just availH → counts fill the visible pane.
|
|
61
|
+
const zWidth = Math.min(availW / needW, 1);
|
|
62
|
+
inner.style.minHeight = Math.floor(availH / Math.max(zWidth, 0.36)) + 'px';
|
|
63
|
+
setFitPct(Math.round(z * 100));
|
|
64
|
+
};
|
|
65
|
+
// The dynamic list counts (useFitCount) and this fit are mutually dependent — the counts fill
|
|
66
|
+
// the frame, which changes its natural height, which changes the fit. Re-run the fit on a short
|
|
67
|
+
// rAF loop (~0.6s) so the pair converges, then keep observing for later resizes/content changes.
|
|
68
|
+
let raf = 0;
|
|
69
|
+
let frames = 0;
|
|
70
|
+
const loop = () => { run(); if (++frames < 36) raf = requestAnimationFrame(loop); };
|
|
71
|
+
loop();
|
|
72
|
+
const ro = new ResizeObserver(run);
|
|
73
|
+
ro.observe(outer);
|
|
74
|
+
ro.observe(inner);
|
|
75
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
|
76
|
+
}, [zoom, edgePad, screen]);
|
|
77
|
+
|
|
78
|
+
const label = SCREENS.find((s) => s.key === screen)?.label ?? '';
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.default' }}>
|
|
82
|
+
<Box sx={{ px: 2.5, py: 1.25, borderBottom: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', gap: 1.5, bgcolor: 'background.paper' }}>
|
|
83
|
+
<DesktopWindowsOutlinedIcon sx={{ fontSize: 22, color: 'text.secondary' }} />
|
|
84
|
+
<Typography variant="h5" sx={{ flex: 1, fontSize: 18, fontWeight: 700, letterSpacing: '-.2px' }}>Previewing {label}</Typography>
|
|
85
|
+
<Select
|
|
86
|
+
size="small" value={zoom}
|
|
87
|
+
onChange={(e) => setZoom(e.target.value === 'fit' ? 'fit' : Number(e.target.value))}
|
|
88
|
+
sx={{ minWidth: 128, fontSize: 13, fontWeight: 600, '& .MuiSelect-select': { py: 0.75 } }}
|
|
89
|
+
>
|
|
90
|
+
<MenuItem value="fit">Fit ({fitPct}%)</MenuItem>
|
|
91
|
+
{ZOOMS.map((z) => <MenuItem key={z} value={z}>{z}%</MenuItem>)}
|
|
92
|
+
</Select>
|
|
93
|
+
</Box>
|
|
94
|
+
|
|
95
|
+
<Box ref={scrollRef} sx={{ flex: 1, overflow: 'auto', p: `${edgePad}px`, position: 'relative', bgcolor: 'background.default' }}>
|
|
96
|
+
{/* #dc-fit-inner — flex column so the preview frame (its single child, flex:1) stretches
|
|
97
|
+
to the minHeight the fit sets, filling the viewport vertically like the original. */}
|
|
98
|
+
<Box ref={innerRef} sx={{ width: '100%', display: 'flex', flexDirection: 'column', '& > *': { flex: '1 1 0%', minHeight: 0 } }}>
|
|
99
|
+
{screen === 'dashboard' && <DashboardPreview />}
|
|
100
|
+
{screen === 'orders' && <OrdersPreview />}
|
|
101
|
+
{screen === 'reports' && <ReportsPreview />}
|
|
102
|
+
{screen === 'cameras' && <CamerasPreview />}
|
|
103
|
+
{screen === 'operations' && <OperationsPreview />}
|
|
104
|
+
{screen === 'components' && <ComponentsPreview />}
|
|
105
|
+
{screen === 'login' && <LoginPreview />}
|
|
106
|
+
</Box>
|
|
107
|
+
{screen !== 'login' && <ToastLayer />}
|
|
108
|
+
</Box>
|
|
109
|
+
</Box>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useTokens } from '../theme/useTokens';
|
|
2
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
3
|
+
import { useFitRows } from './useFitCount';
|
|
4
|
+
|
|
5
|
+
// Reports screen — 1:1 port of the HTML app's reports screen.
|
|
6
|
+
const REPORT_KPIS = [
|
|
7
|
+
{ label: 'Reports Generated', val: '248' },
|
|
8
|
+
{ label: 'Avg Generation', val: '1.2s' },
|
|
9
|
+
{ label: 'Data Points', val: '84.2K' },
|
|
10
|
+
{ label: 'Scheduled', val: '12' },
|
|
11
|
+
];
|
|
12
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
|
|
13
|
+
const H1 = [44, 60, 50, 74, 88, 96];
|
|
14
|
+
const H2 = [24, 32, 30, 42, 52, 60];
|
|
15
|
+
const SERIES = [
|
|
16
|
+
{ l: 'Reports', h: 72 }, { l: 'Cameras', h: 92 }, { l: 'Alerts', h: 40 }, { l: 'Users', h: 58 }, { l: 'Storage', h: 80 },
|
|
17
|
+
];
|
|
18
|
+
const REPORTS = [
|
|
19
|
+
{ name: 'Q2 Revenue Summary', type: 'Financial', date: 'Jun 20', status: 'Ready' },
|
|
20
|
+
{ name: 'Camera Uptime Audit', type: 'Operations', date: 'Jun 19', status: 'Ready' },
|
|
21
|
+
{ name: 'User Access Log', type: 'Security', date: 'Jun 18', status: 'Ready' },
|
|
22
|
+
{ name: 'Inventory Forecast', type: 'Analytics', date: 'Jun 17', status: 'Processing' },
|
|
23
|
+
{ name: 'Monthly KPI Digest', type: 'Executive', date: 'Jun 15', status: 'Ready' },
|
|
24
|
+
{ name: 'Incident Response Log', type: 'Security', date: 'Jun 14', status: 'Ready' },
|
|
25
|
+
{ name: 'Storage Utilization Report', type: 'Operations', date: 'Jun 13', status: 'Ready' },
|
|
26
|
+
{ name: 'Weekly Traffic Analysis', type: 'Analytics', date: 'Jun 12', status: 'Processing' },
|
|
27
|
+
{ name: 'Vendor SLA Compliance', type: 'Financial', date: 'Jun 11', status: 'Ready' },
|
|
28
|
+
{ name: 'Motion Event Rollup', type: 'Operations', date: 'Jun 10', status: 'Ready' },
|
|
29
|
+
{ name: 'Quarterly Audit Trail', type: 'Security', date: 'Jun 09', status: 'Ready' },
|
|
30
|
+
{ name: 'Energy Consumption Digest', type: 'Analytics', date: 'Jun 08', status: 'Processing' },
|
|
31
|
+
{ name: 'Executive Board Pack', type: 'Executive', date: 'Jun 07', status: 'Ready' },
|
|
32
|
+
{ name: 'Device Firmware Ledger', type: 'Operations', date: 'Jun 06', status: 'Ready' },
|
|
33
|
+
{ name: 'Access Anomaly Report', type: 'Security', date: 'Jun 05', status: 'Ready' },
|
|
34
|
+
{ name: 'Cost Allocation Summary', type: 'Financial', date: 'Jun 04', status: 'Ready' },
|
|
35
|
+
];
|
|
36
|
+
const RCOLS = '1fr 130px 110px 90px 70px';
|
|
37
|
+
|
|
38
|
+
export function ReportsPreview() {
|
|
39
|
+
const T = useTokens();
|
|
40
|
+
const s = useThemeStore((st) => st.settings);
|
|
41
|
+
const { t, rad, radSm, pad, padSm, gap, screenPad, rowPy, tH1, tH2, tTitle, wH1: wH, headFam, headLH, accentBg, btnText, btnShadow, tBody, tBodySm, tCaption, tOverline, bwEmph, btnCase, bodyWeightEff, pMain, sMain, chartPalette } = T;
|
|
42
|
+
const headFamCss = headFam + ',sans-serif';
|
|
43
|
+
const bodyFamCss = s.fontFamily + ',sans-serif';
|
|
44
|
+
const hc1 = T.hc1 || t.textPrimary, hc2 = T.hc2 || t.textPrimary, hc3 = T.hc3 || t.textPrimary;
|
|
45
|
+
const cardBorderCss = T.cardBorder === 'none' ? 'none' : T.cardBorder;
|
|
46
|
+
|
|
47
|
+
const [headRef, bodyRef, reportCount] = useFitRows(5, 3, 16);
|
|
48
|
+
const reports = REPORTS.slice(0, reportCount).map((r) => ({ ...r, sb: r.status === 'Ready' ? T.sucSoft : T.warnSoft, sc: r.status === 'Ready' ? T.sucTxt : T.warnTxt }));
|
|
49
|
+
const series = SERIES.map((r, i) => ({ ...r, c: chartPalette[i % chartPalette.length] }));
|
|
50
|
+
|
|
51
|
+
const card: React.CSSProperties = { background: t.paper, border: cardBorderCss, borderRadius: rad, boxShadow: T.cardShadow, overflow: 'hidden' };
|
|
52
|
+
const cardHead: React.CSSProperties = { padding: `${padSm}px ${pad}px`, background: T.cardHdrBg, borderBottom: T.cardHdrBorder, boxShadow: T.cardHdrAccent, display: 'flex', alignItems: 'center', justifyContent: 'space-between' };
|
|
53
|
+
const secTitle: React.CSSProperties = { fontSize: tTitle, lineHeight: headLH, fontWeight: wH, color: hc3, fontFamily: headFamCss };
|
|
54
|
+
const th: React.CSSProperties = { fontSize: tOverline, fontWeight: bwEmph, color: t.textSecondary, textTransform: 'uppercase', letterSpacing: '.5px' };
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div style={{ display: 'flex', flexDirection: 'column', background: t.paper, border: cardBorderCss, borderRadius: rad, boxShadow: T.cardShadow, overflow: 'hidden', fontFamily: bodyFamCss, fontWeight: bodyWeightEff, color: t.textPrimary }}>
|
|
58
|
+
{/* App header */}
|
|
59
|
+
<div style={{ padding: pad, background: T.cardHdrBg, borderBottom: T.cardHdrBorder, boxShadow: T.cardHdrAccent, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 14 }}>
|
|
60
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
61
|
+
<div style={{ fontSize: tH1, lineHeight: headLH, fontWeight: wH, color: hc1, letterSpacing: '-.3px', fontFamily: headFamCss }}>Reports</div>
|
|
62
|
+
<span title="Generated insights across every module" style={{ width: 16, height: 16, borderRadius: '50%', border: '1.2px solid ' + t.textSecondary, color: t.textSecondary, fontSize: 9.5, fontWeight: bwEmph, fontStyle: 'italic', fontFamily: 'Georgia,serif', display: 'grid', placeItems: 'center', flexShrink: 0, cursor: 'help' }}>i</span>
|
|
63
|
+
</div>
|
|
64
|
+
<button style={{ padding: '10px 20px', borderRadius: radSm, border: 'none', background: accentBg, color: btnText, fontSize: tBodySm, fontWeight: bwEmph, textTransform: btnCase as 'uppercase' | 'none', letterSpacing: '.4px', boxShadow: btnShadow, whiteSpace: 'nowrap', cursor: 'pointer' }}>↓ Export PDF</button>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Body */}
|
|
68
|
+
<div style={{ padding: screenPad, background: t.bgDefault, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
69
|
+
{/* KPI row */}
|
|
70
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap, marginBottom: gap }}>
|
|
71
|
+
{REPORT_KPIS.map((k) => (
|
|
72
|
+
<div key={k.label} style={card}>
|
|
73
|
+
<div style={{ padding: `${padSm}px ${pad}px`, background: T.cardHdrBg, borderBottom: T.cardHdrBorder, boxShadow: T.cardHdrAccent }}>
|
|
74
|
+
<span style={{ fontSize: tOverline, color: t.textSecondary, textTransform: 'uppercase', letterSpacing: '.6px', fontWeight: bwEmph }}>{k.label}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div style={{ padding: `${padSm}px ${pad}px` }}>
|
|
77
|
+
<div style={{ fontSize: tH2, fontWeight: wH, color: hc2, letterSpacing: '-.8px', lineHeight: 1 }}>{k.val}</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Charts row */}
|
|
84
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap, marginBottom: gap }}>
|
|
85
|
+
<div style={card}>
|
|
86
|
+
<div style={cardHead}>
|
|
87
|
+
<span style={secTitle}>Revenue & Profit Trend</span>
|
|
88
|
+
<span style={{ fontSize: tCaption, color: t.textSecondary, fontWeight: bwEmph }}>6 months</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div style={{ padding: pad, display: 'flex', alignItems: 'flex-end', gap: 12, height: 160 }}>
|
|
91
|
+
{MONTHS.map((m, i) => (
|
|
92
|
+
<div key={m} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, height: '100%' }}>
|
|
93
|
+
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', gap: 3, flex: 1 }}>
|
|
94
|
+
<div style={{ width: '100%', height: H2[i] + '%', background: sMain, opacity: 0.55, borderRadius: `${radSm} ${radSm} 0 0` }} />
|
|
95
|
+
<div style={{ width: '100%', height: H1[i] + '%', background: pMain, borderRadius: `${radSm} ${radSm} 0 0` }} />
|
|
96
|
+
</div>
|
|
97
|
+
<span style={{ fontSize: tCaption, color: t.textSecondary }}>{m}</span>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
<div style={card}>
|
|
103
|
+
<div style={{ padding: `${padSm}px ${pad}px`, background: T.cardHdrBg, borderBottom: T.cardHdrBorder, boxShadow: T.cardHdrAccent }}>
|
|
104
|
+
<span style={secTitle}>By Module</span>
|
|
105
|
+
</div>
|
|
106
|
+
<div style={{ padding: pad, display: 'flex', alignItems: 'flex-end', gap: 10, height: 128 }}>
|
|
107
|
+
{series.map((r) => (
|
|
108
|
+
<div key={r.l} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', height: '100%' }}>
|
|
109
|
+
<div style={{ width: '100%', height: r.h + '%', background: r.c, borderRadius: `${radSm} ${radSm} 0 0` }} />
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* Recent Reports table */}
|
|
117
|
+
<div style={{ ...card, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
118
|
+
<div style={{ padding: `${padSm}px ${pad}px`, background: T.cardHdrBg, borderBottom: T.cardHdrBorder, boxShadow: T.cardHdrAccent }}>
|
|
119
|
+
<span style={secTitle}>Recent Reports</span>
|
|
120
|
+
</div>
|
|
121
|
+
<div ref={headRef} style={{ display: 'grid', gridTemplateColumns: RCOLS, padding: `${rowPy}px ${pad}px`, borderBottom: '1px solid ' + t.divider, background: t.paper2 }}>
|
|
122
|
+
{['Report', 'Type', 'Status', 'Date'].map((h) => <div key={h} style={th}>{h}</div>)}
|
|
123
|
+
<div />
|
|
124
|
+
</div>
|
|
125
|
+
<div ref={bodyRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
126
|
+
{reports.map((row) => (
|
|
127
|
+
<div key={row.name} style={{ display: 'grid', gridTemplateColumns: RCOLS, padding: `${rowPy}px ${pad}px`, borderBottom: '1px solid ' + t.divider, alignItems: 'center' }}>
|
|
128
|
+
<div style={{ fontSize: tBody, color: t.textPrimary, fontWeight: bwEmph }}>{row.name}</div>
|
|
129
|
+
<div style={{ fontSize: tBodySm, color: t.textSecondary }}>{row.type}</div>
|
|
130
|
+
<div><span style={{ padding: '4px 11px', borderRadius: 999, fontSize: tCaption, fontWeight: bwEmph, background: row.sb, color: row.sc, whiteSpace: 'nowrap' }}>{row.status}</span></div>
|
|
131
|
+
<div style={{ fontSize: tBodySm, color: t.textSecondary }}>{row.date}</div>
|
|
132
|
+
<div><button style={{ fontSize: tCaption, color: pMain, background: t.pContainer, border: 'none', fontWeight: bwEmph, padding: '5px 11px', borderRadius: radSm, cursor: 'pointer' }}>↓ PDF</button></div>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useTokens } from '../theme/useTokens';
|
|
3
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
4
|
+
import { rgbaOf } from '../theme/color';
|
|
5
|
+
|
|
6
|
+
// Sample "Low stock alert" toast — 1:1 with the HTML app's sampleToast. Placement, width,
|
|
7
|
+
// accent and style all come from the token engine (Notifications settings).
|
|
8
|
+
export function ToastLayer() {
|
|
9
|
+
const T = useTokens();
|
|
10
|
+
const s = useThemeStore((st) => st.settings);
|
|
11
|
+
const [dismissed, setDismissed] = useState(false);
|
|
12
|
+
if (dismissed) return null;
|
|
13
|
+
|
|
14
|
+
const { t, npos, nacc, toastDeco, toastBg, rad, radSm, tBodySm, tCaption } = T;
|
|
15
|
+
const width = (s.toastW > 0 ? s.toastW : 252) + 'px';
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div style={{ position: 'absolute', zIndex: 40, width, maxWidth: '70%', boxSizing: 'border-box', background: toastBg, borderRadius: rad, display: 'flex', gap: '10px', alignItems: 'flex-start', padding: '12px 13px', fontFamily: s.fontFamily + ',sans-serif', ...npos, ...toastDeco }}>
|
|
19
|
+
<div style={{ width: '30px', height: '30px', borderRadius: radSm, background: nacc.icbg, color: nacc.icc, display: 'grid', placeItems: 'center', flexShrink: 0, fontSize: '15px', fontWeight: 800 }}>!</div>
|
|
20
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
21
|
+
<div style={{ fontSize: tBodySm + 'px', fontWeight: 700, color: t.textPrimary, marginBottom: '2px' }}>Low stock alert</div>
|
|
22
|
+
<div style={{ fontSize: tCaption + 'px', color: t.textSecondary, lineHeight: 1.4 }}>Widget Pro is down to 12 units left.</div>
|
|
23
|
+
</div>
|
|
24
|
+
{s.notifAutoDismiss && (
|
|
25
|
+
<div title="Auto-dismiss countdown" style={{ flexShrink: 0, marginTop: '1px', minWidth: '26px', height: '20px', padding: '0 7px', borderRadius: '999px', background: rgbaOf(nacc.dot, 0.14), color: nacc.dot, fontSize: (tCaption - 0.5) + 'px', fontWeight: 800, display: 'grid', placeItems: 'center', fontVariantNumeric: 'tabular-nums' }}>
|
|
26
|
+
{(s.autoDismissSec > 0 ? s.autoDismissSec : 8) + 's'}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
<button title="Dismiss notification" onClick={() => setDismissed(true)} style={{ flexShrink: 0, width: '20px', height: '20px', marginTop: '1px', border: 'none', background: 'transparent', color: t.textSecondary, cursor: 'pointer', borderRadius: '6px', display: 'grid', placeItems: 'center', padding: 0, lineHeight: 1 }}>
|
|
30
|
+
<svg width={13} height={13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// Dynamic list length — ported 1:1 from the HTML app's fit logic (actFit / tblFit / reportFit /
|
|
4
|
+
// detFit / notifFit). Measures the average row height and the list's available (flex-filled)
|
|
5
|
+
// height, then shows as many rows as fit, clamped to [min, max]. This is how the original fills
|
|
6
|
+
// vertical space with *content* (more rows) instead of leaving a gap.
|
|
7
|
+
//
|
|
8
|
+
// Usage: const [ref, count] = useFitCount(base, min, max); attach `ref` to the scrolling list
|
|
9
|
+
// wrapper (which must be flex:1 inside its card) and render `pool.slice(0, count)`.
|
|
10
|
+
export function useFitCount(base: number, min: number, max: number) {
|
|
11
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
12
|
+
const [count, setCount] = useState(base);
|
|
13
|
+
|
|
14
|
+
useLayoutEffect(() => {
|
|
15
|
+
const wrap = ref.current;
|
|
16
|
+
if (!wrap) return;
|
|
17
|
+
const run = () => {
|
|
18
|
+
const n = wrap.children.length;
|
|
19
|
+
if (!n) return;
|
|
20
|
+
const gap = parseFloat(getComputedStyle(wrap).rowGap) || 0;
|
|
21
|
+
let sum = 0;
|
|
22
|
+
for (const c of Array.from(wrap.children)) sum += (c as HTMLElement).offsetHeight;
|
|
23
|
+
const rowH = sum / n;
|
|
24
|
+
const avail = wrap.clientHeight;
|
|
25
|
+
if (avail <= 0 || rowH <= 0) return;
|
|
26
|
+
setCount(Math.max(min, Math.min(max, Math.floor((avail + gap) / (rowH + gap)))));
|
|
27
|
+
};
|
|
28
|
+
run();
|
|
29
|
+
const raf = requestAnimationFrame(run);
|
|
30
|
+
const t = setTimeout(run, 300);
|
|
31
|
+
const ro = new ResizeObserver(run);
|
|
32
|
+
ro.observe(wrap);
|
|
33
|
+
return () => { cancelAnimationFrame(raf); clearTimeout(t); ro.disconnect(); };
|
|
34
|
+
}, [min, max]);
|
|
35
|
+
|
|
36
|
+
return [ref, count] as const;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Table variant — ports tblFit / reportFit: use the header-row height as the row unit and divide
|
|
40
|
+
// it into the body's available height (no gap). Attach `headRef` to the header row and `bodyRef`
|
|
41
|
+
// to the flex:1 rows container.
|
|
42
|
+
export function useFitRows(base: number, min: number, max: number) {
|
|
43
|
+
const headRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
const [count, setCount] = useState(base);
|
|
46
|
+
|
|
47
|
+
useLayoutEffect(() => {
|
|
48
|
+
const body = bodyRef.current;
|
|
49
|
+
if (!body) return;
|
|
50
|
+
const run = () => {
|
|
51
|
+
// Use the actual data-row height (the first non-absolute child), not the header — the header
|
|
52
|
+
// can be shorter than rows (which carry status pills / buttons), which would overfill.
|
|
53
|
+
const rows = Array.from(body.children).filter((c) => getComputedStyle(c as HTMLElement).position !== 'absolute');
|
|
54
|
+
const rowH = rows.length ? (rows[0] as HTMLElement).offsetHeight : (headRef.current?.offsetHeight || 36);
|
|
55
|
+
const avail = body.clientHeight;
|
|
56
|
+
if (avail <= 0 || rowH <= 0) return;
|
|
57
|
+
setCount(Math.max(min, Math.min(max, Math.floor(avail / rowH))));
|
|
58
|
+
};
|
|
59
|
+
run();
|
|
60
|
+
const raf = requestAnimationFrame(run);
|
|
61
|
+
const t = setTimeout(run, 300);
|
|
62
|
+
const ro = new ResizeObserver(run);
|
|
63
|
+
ro.observe(body);
|
|
64
|
+
return () => { cancelAnimationFrame(raf); clearTimeout(t); ro.disconnect(); };
|
|
65
|
+
}, [min, max]);
|
|
66
|
+
|
|
67
|
+
return [headRef, bodyRef, count] as const;
|
|
68
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Box, Tooltip } from '@mui/material';
|
|
2
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
3
|
+
import { palette, rgbaOf } from '../theme/color';
|
|
4
|
+
|
|
5
|
+
// Segmented chip selector — tokens ported verbatim from Theme Manager.dc.html chip():
|
|
6
|
+
// active = highlight-tint bg + primary(dark-brand) border + brand-dark text, weight body+200
|
|
7
|
+
// inactive= white/#262b34 bg + neutral border + neutral text.
|
|
8
|
+
export interface ChipOption { key: string; label: string; info?: string; }
|
|
9
|
+
export interface ChipGroupProps { options: ChipOption[]; value: string; columns?: number; onChange: (key: string) => void; }
|
|
10
|
+
|
|
11
|
+
export function ChipGroup({ options, value, columns = 3, onChange }: ChipGroupProps) {
|
|
12
|
+
const mode = useThemeStore((s) => s.settings.mode);
|
|
13
|
+
const primary = useThemeStore((s) => s.settings.primaryHex) || '#1565c0';
|
|
14
|
+
const highlight = useThemeStore((s) => s.settings.highlightHex) || '#ffd400';
|
|
15
|
+
const bodyWeight = useThemeStore((s) => s.settings.bodyWeight) || 400;
|
|
16
|
+
const dark = mode === 'dark';
|
|
17
|
+
const pal = palette(primary);
|
|
18
|
+
const bwEmph = Math.min(900, bodyWeight + 200);
|
|
19
|
+
|
|
20
|
+
const onBg = rgbaOf(highlight, dark ? 0.30 : 0.18);
|
|
21
|
+
const onBorder = dark ? pal[400] : primary;
|
|
22
|
+
const onColor = dark ? pal[200] : pal[700];
|
|
23
|
+
const offBg = dark ? '#262b34' : '#ffffff';
|
|
24
|
+
const offBorder = dark ? '#363d48' : '#e3e8ee';
|
|
25
|
+
const offColor = dark ? '#aeb6c0' : '#5a6573';
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '6px' }}>
|
|
29
|
+
{options.map((o) => {
|
|
30
|
+
const on = o.key === value;
|
|
31
|
+
const el = (
|
|
32
|
+
<Box
|
|
33
|
+
key={o.key}
|
|
34
|
+
onClick={() => onChange(o.key)}
|
|
35
|
+
sx={{
|
|
36
|
+
py: '9px', px: '4px', borderRadius: '10px', textAlign: 'center', cursor: 'pointer', userSelect: 'none',
|
|
37
|
+
fontSize: '11px', fontWeight: on ? bwEmph : bodyWeight,
|
|
38
|
+
border: `1px solid ${on ? onBorder : offBorder}`,
|
|
39
|
+
bgcolor: on ? onBg : offBg,
|
|
40
|
+
color: on ? onColor : offColor,
|
|
41
|
+
transition: 'color .14s ease, background .14s ease, border-color .14s ease',
|
|
42
|
+
'&:hover': on ? {} : { borderColor: dark ? '#4a5361' : '#c9d0d9' },
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{o.label}
|
|
46
|
+
</Box>
|
|
47
|
+
);
|
|
48
|
+
return o.info ? <Tooltip key={o.key} title={o.info} arrow>{el}</Tooltip> : el;
|
|
49
|
+
})}
|
|
50
|
+
</Box>
|
|
51
|
+
);
|
|
52
|
+
}
|