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
package/dist/index.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>OmniEye Theme Studio</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link
|
|
10
|
+
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&family=Inter:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Figtree:wght@400;500;600;700;800&family=Nunito+Sans:wght@400;600;700;800&display=swap"
|
|
11
|
+
rel="stylesheet"
|
|
12
|
+
/>
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-B0UK7Lt_.js"></script>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="root"></div>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
package/index.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>OmniEye Theme Studio</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link
|
|
10
|
+
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&family=Inter:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Figtree:wght@400;500;600;700;800&family=Nunito+Sans:wght@400;600;700;800&display=swap"
|
|
11
|
+
rel="stylesheet"
|
|
12
|
+
/>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="root"></div>
|
|
16
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
package/nginx.conf
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
server {
|
|
2
|
+
listen 80;
|
|
3
|
+
server_name localhost;
|
|
4
|
+
root /usr/share/nginx/html;
|
|
5
|
+
index index.html;
|
|
6
|
+
|
|
7
|
+
location / {
|
|
8
|
+
try_files $uri $uri/ /index.html;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
|
12
|
+
expires 1y;
|
|
13
|
+
add_header Cache-Control "public, immutable";
|
|
14
|
+
}
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "omnieye-theme-studio",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "tsc -b && vite build",
|
|
8
|
+
"preview": "vite preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@emotion/react": "^11.13.3",
|
|
12
|
+
"@emotion/styled": "^11.13.0",
|
|
13
|
+
"@mui/material": "^7.0.0",
|
|
14
|
+
"@mui/icons-material": "^7.0.0",
|
|
15
|
+
"react": "^19.0.0",
|
|
16
|
+
"react-dom": "^19.0.0",
|
|
17
|
+
"zustand": "^5.0.2"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/react": "^19.0.0",
|
|
21
|
+
"@types/react-dom": "^19.0.0",
|
|
22
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
23
|
+
"typescript": "^5.7.2",
|
|
24
|
+
"vite": "^6.0.5"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { Box, Typography, IconButton, Tooltip, Dialog } from '@mui/material';
|
|
3
|
+
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
|
|
4
|
+
import { TopBar } from './chrome/TopBar';
|
|
5
|
+
import { StudioSubHeader } from './chrome/StudioSubHeader';
|
|
6
|
+
import { IconRail } from './chrome/IconRail';
|
|
7
|
+
import { Footer } from './chrome/Footer';
|
|
8
|
+
import { StudioRail } from './studio/StudioRail';
|
|
9
|
+
import { PreviewPane } from './preview/PreviewPane';
|
|
10
|
+
import { useThemeStore } from './store/useThemeStore';
|
|
11
|
+
import { studioTokens } from './theme/studioTokens';
|
|
12
|
+
|
|
13
|
+
const MIN_W = 300, MAX_W = 520;
|
|
14
|
+
|
|
15
|
+
export default function App() {
|
|
16
|
+
const [studioOpen, setStudioOpen] = useState(true);
|
|
17
|
+
const [width, setWidth] = useState(300);
|
|
18
|
+
const [confirmExit, setConfirmExit] = useState(false);
|
|
19
|
+
const isDirty = useThemeStore((s) => s.isDirty);
|
|
20
|
+
const captureBaseline = useThemeStore((s) => s.captureBaseline);
|
|
21
|
+
const restoreBaseline = useThemeStore((s) => s.restoreBaseline);
|
|
22
|
+
const sk = studioTokens(useThemeStore((s) => s.settings.mode));
|
|
23
|
+
const drag = useRef<{ x: number; w: number } | null>(null);
|
|
24
|
+
|
|
25
|
+
const startResize = (e: React.MouseEvent) => {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
drag.current = { x: e.clientX, w: width };
|
|
28
|
+
const move = (ev: MouseEvent) => {
|
|
29
|
+
if (!drag.current) return;
|
|
30
|
+
const w = Math.max(MIN_W, Math.min(MAX_W, drag.current.w + (ev.clientX - drag.current.x)));
|
|
31
|
+
setWidth(w);
|
|
32
|
+
};
|
|
33
|
+
const up = () => { drag.current = null; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
|
|
34
|
+
window.addEventListener('mousemove', move);
|
|
35
|
+
window.addEventListener('mouseup', up);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const requestExit = () => { if (isDirty()) setConfirmExit(true); else setStudioOpen(false); };
|
|
39
|
+
const openStudio = () => { setStudioOpen(true); captureBaseline(); };
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box sx={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
|
|
43
|
+
<TopBar />
|
|
44
|
+
<StudioSubHeader />
|
|
45
|
+
<Box sx={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
|
46
|
+
<IconRail />
|
|
47
|
+
{/* Framed app: studio rail (left) + preview (right) share one bordered card */}
|
|
48
|
+
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', bgcolor: 'background.default', borderTop: '1px solid', borderColor: 'divider' }}>
|
|
49
|
+
{studioOpen ? (
|
|
50
|
+
<Box sx={{ display: 'flex', flexShrink: 0 }}>
|
|
51
|
+
<Box sx={{ width, flexShrink: 0, borderRight: `1px solid ${sk.studioBd}`, display: 'flex', flexDirection: 'column', bgcolor: sk.card, overflow: 'auto' }}>
|
|
52
|
+
<StudioRail onClose={requestExit} />
|
|
53
|
+
</Box>
|
|
54
|
+
<Box onMouseDown={startResize} sx={{ width: 5, cursor: 'col-resize', flexShrink: 0, bgcolor: 'divider', opacity: 0.4, '&:hover': { opacity: 1, bgcolor: 'primary.main' } }} />
|
|
55
|
+
</Box>
|
|
56
|
+
) : (
|
|
57
|
+
<Tooltip title="Open Theme Studio" placement="right" arrow>
|
|
58
|
+
<IconButton onClick={openStudio} sx={{ position: 'absolute', left: 68, bottom: 56, zIndex: 10, bgcolor: 'primary.main', color: 'primary.contrastText', boxShadow: 4, '&:hover': { bgcolor: 'primary.dark' } }}>
|
|
59
|
+
<TuneRoundedIcon />
|
|
60
|
+
</IconButton>
|
|
61
|
+
</Tooltip>
|
|
62
|
+
)}
|
|
63
|
+
<PreviewPane />
|
|
64
|
+
</Box>
|
|
65
|
+
</Box>
|
|
66
|
+
<Footer />
|
|
67
|
+
|
|
68
|
+
<Dialog open={confirmExit} onClose={() => setConfirmExit(false)} PaperProps={{ sx: { borderRadius: 3, width: 300, p: 2.5 } }}>
|
|
69
|
+
<Typography sx={{ fontSize: 15, fontWeight: 800, mb: 0.75 }}>Save changes?</Typography>
|
|
70
|
+
<Typography sx={{ fontSize: 12.5, color: 'text.secondary', lineHeight: 1.5, mb: 2.25 }}>You've made changes to the theme. Do you want to save them before leaving?</Typography>
|
|
71
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
72
|
+
<Box component="button" onClick={() => { captureBaseline(); setConfirmExit(false); setStudioOpen(false); }} sx={{ p: 1.4, borderRadius: 2.5, border: 'none', bgcolor: 'primary.main', color: 'primary.contrastText', fontSize: 13, fontWeight: 700, cursor: 'pointer' }}>Save & exit</Box>
|
|
73
|
+
<Box component="button" onClick={() => { restoreBaseline(); setConfirmExit(false); setStudioOpen(false); }} sx={{ p: 1.4, borderRadius: 2.5, border: '1px solid', borderColor: 'divider', bgcolor: 'action.hover', color: 'text.secondary', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Discard & exit</Box>
|
|
74
|
+
<Box component="button" onClick={() => setConfirmExit(false)} sx={{ p: 1.1, borderRadius: 2.5, border: 'none', bgcolor: 'transparent', color: 'text.secondary', fontSize: 12.5, fontWeight: 600, cursor: 'pointer' }}>Keep editing</Box>
|
|
75
|
+
</Box>
|
|
76
|
+
</Dialog>
|
|
77
|
+
</Box>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Box, Typography } from '@mui/material';
|
|
2
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
3
|
+
import { useStudioUi } from '../store/useStudioUi';
|
|
4
|
+
import { PRESETS } from '../theme/presets';
|
|
5
|
+
|
|
6
|
+
export function Footer() {
|
|
7
|
+
const settings = useThemeStore((s) => s.settings);
|
|
8
|
+
const saved = useThemeStore((s) => s.savedThemes);
|
|
9
|
+
const activeName =
|
|
10
|
+
PRESETS.find((p) => p.id === settings.presetId)?.name ||
|
|
11
|
+
saved.find((z) => z.id === settings.presetId)?.name ||
|
|
12
|
+
'Custom Theme';
|
|
13
|
+
// touch the store subscription so the footer stays reactive to screen changes
|
|
14
|
+
useStudioUi((s) => s.previewScreen);
|
|
15
|
+
return (
|
|
16
|
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2, py: 0.75, borderTop: '1px solid', borderColor: 'divider', bgcolor: 'background.paper' }}>
|
|
17
|
+
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>Theme Studio · OmniEye</Typography>
|
|
18
|
+
<Typography variant="caption" sx={{ color: 'text.disabled' }}>{activeName} · v1.0</Typography>
|
|
19
|
+
</Box>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Box, Tooltip, Avatar } from '@mui/material';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
|
|
4
|
+
import HubOutlinedIcon from '@mui/icons-material/HubOutlined';
|
|
5
|
+
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
|
|
6
|
+
import VideocamOutlinedIcon from '@mui/icons-material/VideocamOutlined';
|
|
7
|
+
import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined';
|
|
8
|
+
import { useStudioUi, type ScreenKey } from '../store/useStudioUi';
|
|
9
|
+
|
|
10
|
+
// Left nav — the HTML app's 5 ERP nav items (Dashboard, Operations, Orders, Cameras, Reports).
|
|
11
|
+
const NAV: { key: ScreenKey; label: string; icon: ReactNode }[] = [
|
|
12
|
+
{ key: 'dashboard', label: 'Dashboard', icon: <DashboardOutlinedIcon sx={{ fontSize: 20 }} /> },
|
|
13
|
+
{ key: 'operations', label: 'Operations', icon: <HubOutlinedIcon sx={{ fontSize: 20 }} /> },
|
|
14
|
+
{ key: 'orders', label: 'Orders', icon: <ReceiptLongOutlinedIcon sx={{ fontSize: 20 }} /> },
|
|
15
|
+
{ key: 'cameras', label: 'Cameras', icon: <VideocamOutlinedIcon sx={{ fontSize: 20 }} /> },
|
|
16
|
+
{ key: 'reports', label: 'Reports', icon: <AssessmentOutlinedIcon sx={{ fontSize: 20 }} /> },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Thin far-left icon rail — switches the preview screen (matches the HTML app's collapsed sidebar).
|
|
20
|
+
export function IconRail() {
|
|
21
|
+
const screen = useStudioUi((s) => s.previewScreen);
|
|
22
|
+
const setScreen = useStudioUi((s) => s.setPreviewScreen);
|
|
23
|
+
return (
|
|
24
|
+
<Box sx={{ width: 56, flexShrink: 0, borderRight: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5, py: 1.25 }}>
|
|
25
|
+
{NAV.map((n) => {
|
|
26
|
+
const on = screen === n.key;
|
|
27
|
+
return (
|
|
28
|
+
<Tooltip key={n.key} title={n.label} placement="right" arrow>
|
|
29
|
+
<Box
|
|
30
|
+
onClick={() => setScreen(n.key)}
|
|
31
|
+
sx={{
|
|
32
|
+
width: 38, height: 38, borderRadius: 2.5, display: 'grid', placeItems: 'center', cursor: 'pointer',
|
|
33
|
+
color: on ? 'primary.main' : 'text.secondary',
|
|
34
|
+
bgcolor: on ? 'action.selected' : 'transparent',
|
|
35
|
+
transition: 'all .14s ease', '&:hover': { bgcolor: 'action.hover' },
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
{n.icon}
|
|
39
|
+
</Box>
|
|
40
|
+
</Tooltip>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
<Box sx={{ flex: 1 }} />
|
|
44
|
+
<Tooltip title="Account" placement="right" arrow>
|
|
45
|
+
<Avatar sx={{ width: 34, height: 34, bgcolor: 'secondary.main', fontSize: 13, fontWeight: 800 }}>EA</Avatar>
|
|
46
|
+
</Tooltip>
|
|
47
|
+
</Box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Box, Typography } from '@mui/material';
|
|
2
|
+
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
|
|
3
|
+
|
|
4
|
+
// The "Theme Studio" identity bar, directly under the top bar (full width).
|
|
5
|
+
export function StudioSubHeader() {
|
|
6
|
+
return (
|
|
7
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, px: 2, py: 1, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.paper' }}>
|
|
8
|
+
<Box sx={{ width: 30, height: 30, borderRadius: 2, background: 'linear-gradient(135deg,#7c4dff,#5b8def)', color: '#fff', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
|
9
|
+
<AccountTreeOutlinedIcon sx={{ fontSize: 18 }} />
|
|
10
|
+
</Box>
|
|
11
|
+
<Typography variant="h5" sx={{ fontSize: 17, fontWeight: 700, letterSpacing: '-.2px' }}>Theme Studio</Typography>
|
|
12
|
+
</Box>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AppBar, Toolbar, Box, Typography, IconButton, Tooltip, Badge } from '@mui/material';
|
|
2
|
+
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
|
|
3
|
+
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
|
|
4
|
+
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
|
5
|
+
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
|
6
|
+
import NotificationsNoneOutlinedIcon from '@mui/icons-material/NotificationsNoneOutlined';
|
|
7
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
8
|
+
|
|
9
|
+
// OmniEye is the fixed product brand; the themed tenant app name lives in settings.appName.
|
|
10
|
+
export function TopBar() {
|
|
11
|
+
const mode = useThemeStore((st) => st.settings.mode);
|
|
12
|
+
const set = useThemeStore((st) => st.set);
|
|
13
|
+
return (
|
|
14
|
+
<AppBar position="static" color="default" elevation={0} sx={{ borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.paper' }}>
|
|
15
|
+
<Toolbar variant="dense" sx={{ minHeight: 56, gap: 1.5, px: 2 }}>
|
|
16
|
+
<Box sx={{ width: 34, height: 34, borderRadius: 2.5, bgcolor: 'primary.main', color: 'primary.contrastText', display: 'grid', placeItems: 'center', flexShrink: 0, fontSize: 14, fontWeight: 800, letterSpacing: '.3px' }}>
|
|
17
|
+
OE
|
|
18
|
+
</Box>
|
|
19
|
+
<Typography variant="h6" sx={{ flex: 1, fontWeight: 800, letterSpacing: '-.3px' }}>OmniEye</Typography>
|
|
20
|
+
<Tooltip title="Toggle light / dark" arrow>
|
|
21
|
+
<IconButton size="small" onClick={() => set({ mode: mode === 'dark' ? 'light' : 'dark' })} sx={{ color: 'text.secondary' }}>
|
|
22
|
+
{mode === 'dark' ? <LightModeOutlinedIcon sx={{ fontSize: 20 }} /> : <DarkModeOutlinedIcon sx={{ fontSize: 20 }} />}
|
|
23
|
+
</IconButton>
|
|
24
|
+
</Tooltip>
|
|
25
|
+
<Tooltip title="About" arrow><IconButton size="small" sx={{ color: 'text.secondary' }}><InfoOutlinedIcon sx={{ fontSize: 20 }} /></IconButton></Tooltip>
|
|
26
|
+
<Tooltip title="Settings" arrow><IconButton size="small" sx={{ color: 'text.secondary' }}><SettingsOutlinedIcon sx={{ fontSize: 20 }} /></IconButton></Tooltip>
|
|
27
|
+
<Tooltip title="Notifications" arrow>
|
|
28
|
+
<IconButton size="small" sx={{ color: 'text.secondary' }}>
|
|
29
|
+
<Badge color="error" variant="dot"><NotificationsNoneOutlinedIcon sx={{ fontSize: 20 }} /></Badge>
|
|
30
|
+
</IconButton>
|
|
31
|
+
</Tooltip>
|
|
32
|
+
</Toolbar>
|
|
33
|
+
</AppBar>
|
|
34
|
+
);
|
|
35
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import { CssBaseline, ThemeProvider } from '@mui/material';
|
|
4
|
+
import { useThemeStore } from './store/useThemeStore';
|
|
5
|
+
import { buildMuiTheme } from './theme/buildMuiTheme';
|
|
6
|
+
import App from './App';
|
|
7
|
+
|
|
8
|
+
function Root() {
|
|
9
|
+
const settings = useThemeStore((s) => s.settings);
|
|
10
|
+
const theme = React.useMemo(() => buildMuiTheme(settings), [settings]);
|
|
11
|
+
return (
|
|
12
|
+
<ThemeProvider theme={theme}>
|
|
13
|
+
<CssBaseline />
|
|
14
|
+
<App />
|
|
15
|
+
</ThemeProvider>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
20
|
+
<React.StrictMode>
|
|
21
|
+
<Root />
|
|
22
|
+
</React.StrictMode>
|
|
23
|
+
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Box } from '@mui/material';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
4
|
+
import { TabStrip } from '../shared/TabStrip';
|
|
5
|
+
import { ChipGroup } from '../shared/ChipGroup';
|
|
6
|
+
import { StudioSlider } from '../shared/StudioSlider';
|
|
7
|
+
|
|
8
|
+
// Cards panel — 1:1 with the HTML app: Style / Header / Nested / Tint tabs.
|
|
9
|
+
const CARD_SHADOW: Record<string, number> = { outlined: 0, soft: 6, floating: 24 };
|
|
10
|
+
const HEADER_STRENGTH: Record<string, number> = { plain: 0, tinted: 40, accent: 60 };
|
|
11
|
+
const NEST_DEPTH: Record<string, number> = { bordered: 0, recessed: 6, lighter: 4 };
|
|
12
|
+
|
|
13
|
+
export function CardsPanel() {
|
|
14
|
+
const [tab, setTab] = useState('style');
|
|
15
|
+
const s = useThemeStore((st) => st.settings);
|
|
16
|
+
const set = useThemeStore((st) => st.set);
|
|
17
|
+
|
|
18
|
+
const cardShadowEff = s.cardShadowPx > 0 ? s.cardShadowPx : (CARD_SHADOW[s.cardStyle] ?? 24);
|
|
19
|
+
const headerStrengthEff = s.headerStrength > 0 ? s.headerStrength : (HEADER_STRENGTH[s.cardHeader] ?? 45);
|
|
20
|
+
const nestedDepthEff = s.nestedDepth > 0 ? s.nestedDepth : (NEST_DEPTH[s.nestedStyle] ?? 6);
|
|
21
|
+
const tintIntensityEff = s.tintIntensity > 0 ? s.tintIntensity : (s.surfaceTint === 'snow' ? 0 : 50);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<TabStrip
|
|
26
|
+
active={tab}
|
|
27
|
+
onChange={setTab}
|
|
28
|
+
tabs={[
|
|
29
|
+
{ key: 'style', label: 'Style', info: 'Card border vs shadow depth.' },
|
|
30
|
+
{ key: 'header', label: 'Header', info: 'Card header treatment.' },
|
|
31
|
+
{ key: 'nested', label: 'Nested', info: 'How cards-within-cards look.' },
|
|
32
|
+
{ key: 'tint', label: 'Tint', info: 'Overall surface tint colour & strength.' },
|
|
33
|
+
]}
|
|
34
|
+
/>
|
|
35
|
+
<Box sx={{ p: '12px 2px 2px' }}>
|
|
36
|
+
{tab === 'style' && (
|
|
37
|
+
<>
|
|
38
|
+
<ChipGroup value={s.cardStyle} onChange={(v) => set({ cardStyle: v as 'outlined' | 'soft' | 'floating' })}
|
|
39
|
+
options={[{ key: 'outlined', label: 'Outlined' }, { key: 'soft', label: 'Soft' }, { key: 'floating', label: 'Floating' }]} />
|
|
40
|
+
<Box sx={{ mt: 1.5 }}>
|
|
41
|
+
<StudioSlider label="Shadow depth" info="Card elevation (px)." value={s.cardShadowPx} effective={cardShadowEff} min={0} max={40} unit="px" onChange={(v) => set({ cardShadowPx: v })} />
|
|
42
|
+
</Box>
|
|
43
|
+
</>
|
|
44
|
+
)}
|
|
45
|
+
{tab === 'header' && (
|
|
46
|
+
<>
|
|
47
|
+
<ChipGroup value={s.cardHeader} onChange={(v) => set({ cardHeader: v as 'plain' | 'tinted' | 'accent' })}
|
|
48
|
+
options={[{ key: 'plain', label: 'Plain' }, { key: 'tinted', label: 'Tinted' }, { key: 'accent', label: 'Accent' }]} />
|
|
49
|
+
<Box sx={{ mt: 1.5 }}>
|
|
50
|
+
<StudioSlider label="Header strength" info="Header tint / accent intensity." value={s.headerStrength} effective={headerStrengthEff} min={0} max={100} unit="%" onChange={(v) => set({ headerStrength: v })} />
|
|
51
|
+
</Box>
|
|
52
|
+
</>
|
|
53
|
+
)}
|
|
54
|
+
{tab === 'nested' && (
|
|
55
|
+
<>
|
|
56
|
+
<ChipGroup value={s.nestedStyle} onChange={(v) => set({ nestedStyle: v as 'recessed' | 'lighter' | 'bordered' })}
|
|
57
|
+
options={[{ key: 'recessed', label: 'Recessed' }, { key: 'lighter', label: 'Lighter' }, { key: 'bordered', label: 'Bordered' }]} />
|
|
58
|
+
<Box sx={{ mt: 1.5 }}>
|
|
59
|
+
<StudioSlider label="Nested depth" info="Inset depth of cards-within-cards." value={s.nestedDepth} effective={nestedDepthEff} min={0} max={24} unit="px" onChange={(v) => set({ nestedDepth: v })} />
|
|
60
|
+
</Box>
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
63
|
+
{tab === 'tint' && (
|
|
64
|
+
<>
|
|
65
|
+
<ChipGroup value={s.surfaceTint} onChange={(v) => set({ surfaceTint: v as 'snow' | 'neutral' | 'brand' })}
|
|
66
|
+
options={[{ key: 'snow', label: 'None' }, { key: 'neutral', label: 'Low' }, { key: 'brand', label: 'High' }]} />
|
|
67
|
+
<Box sx={{ mt: 1.5 }}>
|
|
68
|
+
<StudioSlider label="Tint intensity" info="Strength of the page surface tint." value={s.tintIntensity} effective={tintIntensityEff} min={0} max={100} unit="%" onChange={(v) => set({ tintIntensity: v })} />
|
|
69
|
+
</Box>
|
|
70
|
+
</>
|
|
71
|
+
)}
|
|
72
|
+
</Box>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Box } from '@mui/material';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
4
|
+
import { TabStrip } from '../shared/TabStrip';
|
|
5
|
+
import { studioTokens } from '../theme/studioTokens';
|
|
6
|
+
import { useTokens } from '../theme/useTokens';
|
|
7
|
+
import { palette } from '../theme/color';
|
|
8
|
+
|
|
9
|
+
// ── shared bits ────────────────────────────────────────────────────────────
|
|
10
|
+
function InfoDot({ tip, sk }: { tip: string; sk: ReturnType<typeof studioTokens> }) {
|
|
11
|
+
return (
|
|
12
|
+
<Box component="span" title={tip}
|
|
13
|
+
sx={{ width: 13, height: 13, borderRadius: '50%', border: `1.1px solid ${sk.chevron}`, color: sk.chevron, fontSize: 8, fontWeight: 800, fontStyle: 'italic', fontFamily: 'Georgia, serif', display: 'inline-grid', placeItems: 'center', flexShrink: 0, cursor: 'help', verticalAlign: 'middle' }}>i</Box>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function SectionLabel({ children, tip, sk, mt }: { children: React.ReactNode; tip?: string; sk: ReturnType<typeof studioTokens>; mt?: number }) {
|
|
18
|
+
const T = useTokens();
|
|
19
|
+
return (
|
|
20
|
+
<Box sx={{ fontSize: `${T.tCaption}px`, fontWeight: T.bwEmph, fontFamily: `${T.bodyFam},sans-serif`, color: sk.label2, textTransform: 'uppercase', letterSpacing: '.5px', mb: '6px', mt: mt ? `${mt}px` : 0, display: 'flex', alignItems: 'center', gap: '5px' }}>
|
|
21
|
+
{children}{tip && <InfoDot tip={tip} sk={sk} />}
|
|
22
|
+
</Box>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A colour "well" — a swatch that opens the native colour input laid transparently on top.
|
|
27
|
+
function ColorWell({ value, onChange, w = 30, h = 24, r = 7, border, sk }: { value: string; onChange: (hex: string) => void; w?: number; h?: number; r?: number; border?: boolean; sk: ReturnType<typeof studioTokens> }) {
|
|
28
|
+
return (
|
|
29
|
+
<Box sx={{ width: w, height: h, borderRadius: `${r}px`, position: 'relative', overflow: 'hidden', flexShrink: 0, background: value, border: border ? `1px solid ${sk.insetBd}` : 'none' }}>
|
|
30
|
+
<Box component="input" type="color" value={value} onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
31
|
+
sx={{ position: 'absolute', top: -4, left: -4, width: w + 10, height: h + 10, opacity: 0, cursor: 'pointer', border: 'none', p: 0 }} />
|
|
32
|
+
</Box>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ColourPanel() {
|
|
37
|
+
const [tab, setTab] = useState('brand');
|
|
38
|
+
const s = useThemeStore((st) => st.settings);
|
|
39
|
+
const set = useThemeStore((st) => st.set);
|
|
40
|
+
const sk = studioTokens(s.mode);
|
|
41
|
+
const T = useTokens();
|
|
42
|
+
// Body / status / surface swatch labels are a fixed 11.5px/600 in the HTML; the heading swatch
|
|
43
|
+
// labels (H1…H6) are body-text-driven (tBodySm + body weight + body font).
|
|
44
|
+
const label = { fontSize: '11.5px', color: sk.body, fontWeight: 600 };
|
|
45
|
+
const headLabel = { fontSize: `${T.tBodySm}px`, color: sk.body, fontWeight: T.bodyWeightEff, fontFamily: `${T.bodyFam},sans-serif`, lineHeight: T.bodyLH };
|
|
46
|
+
|
|
47
|
+
const primary = s.primaryHex || '#1565c0';
|
|
48
|
+
const pal = palette(primary);
|
|
49
|
+
const palRow = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900].map((k) => pal[k]);
|
|
50
|
+
|
|
51
|
+
const setBrand = (patch: Record<string, string>) => set({ ...patch, presetId: 'custom' } as never);
|
|
52
|
+
|
|
53
|
+
const headings: { key: keyof typeof s; label: string }[] = [
|
|
54
|
+
{ key: 'h1Color', label: 'H1 · Page Title' }, { key: 'h2Color', label: 'H2 · Metric' },
|
|
55
|
+
{ key: 'h3Color', label: 'H3 · Card Title' }, { key: 'h4Color', label: 'H4 · Section Label' },
|
|
56
|
+
{ key: 'h5Color', label: 'H5 · Caption' }, { key: 'h6Color', label: 'H6 · Overline' },
|
|
57
|
+
];
|
|
58
|
+
const statuses: { key: keyof typeof s; label: string }[] = [
|
|
59
|
+
{ key: 'successHex', label: 'Success' }, { key: 'warningHex', label: 'Warning' },
|
|
60
|
+
{ key: 'infoHex', label: 'Info' }, { key: 'errorHex', label: 'Error' },
|
|
61
|
+
];
|
|
62
|
+
const surfaces: { key: keyof typeof s; label: string; fb: string }[] = [
|
|
63
|
+
{ key: 'cardBgHex', label: 'Card background', fb: '#ffffff' },
|
|
64
|
+
{ key: 'cardHdrHex', label: 'Card header', fb: '#f6f8fb' },
|
|
65
|
+
{ key: 'pageBgHex', label: 'Page background', fb: '#f4f6f9' },
|
|
66
|
+
{ key: 'notifCardHex', label: 'Notification background', fb: '#ffffff' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '11px' }}>
|
|
71
|
+
<TabStrip active={tab} onChange={setTab} tabs={[
|
|
72
|
+
{ key: 'brand', label: 'Brand', info: 'Core brand colours — primary, secondary and the selection highlight colour used across the app.' },
|
|
73
|
+
{ key: 'text', label: 'Text', info: 'Heading and body text colours' },
|
|
74
|
+
{ key: 'status', label: 'Status', info: 'Status colours & surface overrides' },
|
|
75
|
+
]} />
|
|
76
|
+
{/* content wrapper — sibling of the tab strip so the studio body's 11px gap + this 12px
|
|
77
|
+
top padding reproduce the original ~25px tabs→content spacing. */}
|
|
78
|
+
<Box sx={{ p: '12px 2px 2px', display: 'flex', flexDirection: 'column', gap: '11px' }}>
|
|
79
|
+
|
|
80
|
+
{/* ── BRAND ── */}
|
|
81
|
+
{tab === 'brand' && (
|
|
82
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '11px' }}>
|
|
83
|
+
<Box>
|
|
84
|
+
<SectionLabel sk={sk} tip="Primary brand colour — main/filled buttons, active sidebar item, links, focus rings, first chart series, and the login Sign-in button.">Brand Color</SectionLabel>
|
|
85
|
+
<Box sx={{ position: 'relative', height: 46, borderRadius: '11px', background: primary, overflow: 'hidden', display: 'flex', alignItems: 'center', px: '14px', boxShadow: `0 3px 10px ${primary}44` }}>
|
|
86
|
+
<Box component="span" sx={{ color: 'rgba(255,255,255,.85)', fontSize: '12.5px', fontWeight: 700, pointerEvents: 'none' }}>{primary} · tap to change</Box>
|
|
87
|
+
<Box component="input" type="color" value={primary} onChange={(e) => setBrand({ primaryHex: (e.target as HTMLInputElement).value })} sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', border: 'none', p: 0 }} />
|
|
88
|
+
</Box>
|
|
89
|
+
<Box sx={{ display: 'flex', gap: '2px', borderRadius: '7px', overflow: 'hidden', height: 14, mt: '7px' }}>
|
|
90
|
+
{palRow.map((c, i) => <Box key={i} sx={{ flex: 1, background: c }} />)}
|
|
91
|
+
</Box>
|
|
92
|
+
</Box>
|
|
93
|
+
<Box>
|
|
94
|
+
<SectionLabel sk={sk} tip="Secondary accent — secondary/outlined buttons, switches & toggles, selected chips, and the second data series in charts.">Secondary / Accent</SectionLabel>
|
|
95
|
+
<Box sx={{ position: 'relative', height: 34, borderRadius: '9px', background: s.secondaryHex, overflow: 'hidden' }}>
|
|
96
|
+
<Box component="input" type="color" value={s.secondaryHex} onChange={(e) => setBrand({ secondaryHex: (e.target as HTMLInputElement).value })} sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', border: 'none', p: 0 }} />
|
|
97
|
+
</Box>
|
|
98
|
+
</Box>
|
|
99
|
+
<Box>
|
|
100
|
+
<SectionLabel sk={sk} tip="Accent colour used to draw attention — highlight panels, featured badges, callouts. Defaults to the brand colour.">Highlight Colour</SectionLabel>
|
|
101
|
+
<Box sx={{ position: 'relative', height: 34, borderRadius: '8px', background: s.highlightHex || '#ffd400', overflow: 'hidden', border: `1px solid ${sk.insetBd}` }}>
|
|
102
|
+
<Box component="input" type="color" value={s.highlightHex || '#ffd400'} onChange={(e) => setBrand({ highlightHex: (e.target as HTMLInputElement).value })} sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', border: 'none', p: 0 }} />
|
|
103
|
+
</Box>
|
|
104
|
+
</Box>
|
|
105
|
+
</Box>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* ── TEXT ── */}
|
|
109
|
+
{tab === 'text' && (
|
|
110
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '11px' }}>
|
|
111
|
+
<SectionLabel sk={sk}>Heading Text Colors</SectionLabel>
|
|
112
|
+
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '9px' }}>
|
|
113
|
+
{headings.map((h) => (
|
|
114
|
+
<Box key={h.key as string} sx={{ display: 'flex', alignItems: 'center', gap: '9px' }}>
|
|
115
|
+
<ColorWell sk={sk} border value={(s[h.key] as string) || '#1c2127'} onChange={(v) => set({ [h.key]: v } as never)} />
|
|
116
|
+
<Box component="span" sx={headLabel}>{h.label}</Box>
|
|
117
|
+
</Box>
|
|
118
|
+
))}
|
|
119
|
+
</Box>
|
|
120
|
+
<SectionLabel sk={sk}>Body Text Colors</SectionLabel>
|
|
121
|
+
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '7px' }}>
|
|
122
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: '9px' }}>
|
|
123
|
+
<ColorWell sk={sk} border value={s.textPrimaryHex || '#1c2127'} onChange={(v) => set({ textPrimaryHex: v })} />
|
|
124
|
+
<Box component="span" sx={label} title="Main body text colour.">Primary Text</Box>
|
|
125
|
+
</Box>
|
|
126
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: '9px' }}>
|
|
127
|
+
<ColorWell sk={sk} border value={s.textSecondaryHex || '#5a6573'} onChange={(v) => set({ textSecondaryHex: v })} />
|
|
128
|
+
<Box component="span" sx={label} title="Muted text colour.">Secondary Text</Box>
|
|
129
|
+
</Box>
|
|
130
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: '9px' }}>
|
|
131
|
+
<ColorWell sk={sk} border value={s.dividerHex || '#e3e8ee'} onChange={(v) => set({ dividerHex: v })} />
|
|
132
|
+
<Box component="span" sx={label} title="Divider / border colour.">Divider</Box>
|
|
133
|
+
</Box>
|
|
134
|
+
</Box>
|
|
135
|
+
</Box>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{/* ── STATUS ── */}
|
|
139
|
+
{tab === 'status' && (
|
|
140
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '7px' }}>
|
|
141
|
+
<Box>
|
|
142
|
+
<SectionLabel sk={sk} tip="Success, warning, error & info colours — used for alerts, status pills, toasts and validation states.">Status Colors</SectionLabel>
|
|
143
|
+
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '7px' }}>
|
|
144
|
+
{statuses.map((st) => (
|
|
145
|
+
<Box key={st.key as string} sx={{ display: 'flex', alignItems: 'center', gap: '8px', background: sk.card, border: `1px solid ${sk.insetBd}`, borderRadius: '9px', px: '9px', py: '7px' }}>
|
|
146
|
+
<ColorWell sk={sk} w={22} h={22} r={6} value={s[st.key] as string} onChange={(v) => set({ [st.key]: v } as never)} />
|
|
147
|
+
<Box component="span" sx={label}>{st.label}</Box>
|
|
148
|
+
</Box>
|
|
149
|
+
))}
|
|
150
|
+
</Box>
|
|
151
|
+
</Box>
|
|
152
|
+
<Box>
|
|
153
|
+
<SectionLabel sk={sk} tip="Override the auto-generated surface colours. Leave on Auto to follow the surface tint & mode.">Surfaces</SectionLabel>
|
|
154
|
+
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '7px' }}>
|
|
155
|
+
{surfaces.map((su) => {
|
|
156
|
+
const on = !!(s[su.key] as string);
|
|
157
|
+
return (
|
|
158
|
+
<Box key={su.key as string} sx={{ display: 'flex', alignItems: 'center', gap: '8px', background: sk.card, border: `1px solid ${sk.insetBd}`, borderRadius: '9px', px: '9px', py: '5px' }}>
|
|
159
|
+
<ColorWell sk={sk} w={22} h={22} r={6} border value={(s[su.key] as string) || su.fb} onChange={(v) => set({ [su.key]: v } as never)} />
|
|
160
|
+
<Box component="span" sx={{ ...label, flex: 1 }}>{su.label}</Box>
|
|
161
|
+
{on && (
|
|
162
|
+
<Box component="button" title="Reset to Auto" onClick={() => set({ [su.key]: '' } as never)}
|
|
163
|
+
sx={{ background: 'transparent', border: `1px solid ${sk.insetBd}`, borderRadius: '6px', color: sk.muted, fontSize: '10px', fontWeight: 700, px: '7px', py: '3px', cursor: 'pointer' }}>Auto</Box>
|
|
164
|
+
)}
|
|
165
|
+
</Box>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</Box>
|
|
169
|
+
</Box>
|
|
170
|
+
</Box>
|
|
171
|
+
)}
|
|
172
|
+
</Box>
|
|
173
|
+
</Box>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Box, Typography } from '@mui/material';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useThemeStore } from '../store/useThemeStore';
|
|
4
|
+
import { SwatchPicker } from '../shared/SwatchPicker';
|
|
5
|
+
import { studioTokens } from '../theme/studioTokens';
|
|
6
|
+
import { CropModal } from '../shared/CropModal';
|
|
7
|
+
|
|
8
|
+
// Login Screen panel — 1:1 with the open preview: Brand Image, Logo, Panel Colour. Nothing else.
|
|
9
|
+
export function LoginPanel() {
|
|
10
|
+
const s = useThemeStore((st) => st.settings);
|
|
11
|
+
const set = useThemeStore((st) => st.set);
|
|
12
|
+
const sk = studioTokens(s.mode);
|
|
13
|
+
const [crop, setCrop] = useState<{ src: string; aspect: number; target: 'loginImage' | 'logoUrl' } | null>(null);
|
|
14
|
+
|
|
15
|
+
const onFile = (e: React.ChangeEvent<HTMLInputElement>, key: 'loginImage' | 'logoUrl') => {
|
|
16
|
+
const f = e.target.files?.[0];
|
|
17
|
+
if (!f) return;
|
|
18
|
+
const rd = new FileReader();
|
|
19
|
+
rd.onload = () => setCrop({ src: rd.result as string, aspect: key === 'logoUrl' ? 3.5 : 1.55, target: key });
|
|
20
|
+
rd.readAsDataURL(f);
|
|
21
|
+
e.target.value = '';
|
|
22
|
+
};
|
|
23
|
+
const applyCrop = (url: string) => {
|
|
24
|
+
if (!crop) return;
|
|
25
|
+
if (crop.target === 'logoUrl') set({ logoUrl: url, logoMode: 'image' });
|
|
26
|
+
else set({ loginImage: url });
|
|
27
|
+
setCrop(null);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const label: React.CSSProperties = { fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.6px' };
|
|
31
|
+
const dropSx = { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, border: `1.5px dashed ${sk.insetBd}`, borderRadius: 2.5, p: 1.75, cursor: 'pointer', color: sk.body, fontSize: 12.5, fontWeight: 600, bgcolor: sk.inset } as const;
|
|
32
|
+
const chip = (name: string, onClear: () => void) => (
|
|
33
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1, p: 0.9, bgcolor: sk.inset, border: `1px solid ${sk.insetBd}`, borderRadius: 2 }}>
|
|
34
|
+
<Typography variant="caption" sx={{ flex: 1, minWidth: 0, color: sk.body, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</Typography>
|
|
35
|
+
<Box component="button" onClick={onClear} title="Remove" sx={{ border: 'none', background: 'transparent', color: sk.muted, cursor: 'pointer', fontSize: 14 }}>✕</Box>
|
|
36
|
+
</Box>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75 }}>
|
|
41
|
+
<Box>
|
|
42
|
+
<Typography variant="caption" sx={{ ...label, color: sk.label2, display: 'block', mb: 1 }}>Brand Image</Typography>
|
|
43
|
+
<Box component="label" sx={dropSx}>⬆ Choose image
|
|
44
|
+
<input hidden type="file" accept="image/*" onChange={(e) => onFile(e, 'loginImage')} />
|
|
45
|
+
</Box>
|
|
46
|
+
{s.loginImage && chip('Uploaded image', () => set({ loginImage: '' }))}
|
|
47
|
+
</Box>
|
|
48
|
+
<Box>
|
|
49
|
+
<Typography variant="caption" sx={{ ...label, color: sk.label2, display: 'block', mb: 1 }}>Logo</Typography>
|
|
50
|
+
<Box component="label" sx={dropSx}>⬆ Choose logo file
|
|
51
|
+
<input hidden type="file" accept="image/png,image/svg+xml,image/webp" onChange={(e) => onFile(e, 'logoUrl')} />
|
|
52
|
+
</Box>
|
|
53
|
+
{s.logoMode === 'image' && s.logoUrl && chip('Uploaded logo', () => set({ logoUrl: '', logoMode: 'icon' }))}
|
|
54
|
+
</Box>
|
|
55
|
+
<Box>
|
|
56
|
+
<Typography variant="caption" sx={{ ...label, color: sk.label2, display: 'block', mb: 0.5 }}>Panel Colour</Typography>
|
|
57
|
+
<SwatchPicker label="Login panel background" value={s.loginPanelColor} fallback={s.primaryHex} onChange={(v) => set({ loginPanelColor: v })} onReset={() => set({ loginPanelColor: '' })} />
|
|
58
|
+
</Box>
|
|
59
|
+
{crop && <CropModal src={crop.src} aspect={crop.aspect} onCancel={() => setCrop(null)} onConfirm={applyCrop} />}
|
|
60
|
+
</Box>
|
|
61
|
+
);
|
|
62
|
+
}
|