feat(ui): Implement Dynamic Color system and M3 Forms (Stage 2)
- Integrated @material/material-color-utilities for HCT-based color generation - Implemented M3 Filled Text Field with floating labels and state layers - Added Seed Color picker and palette preview to Settings - Updated Login page with new M3 components - Enhanced ThemeProvider with system preference listeners Refs #3 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
6
package-lock.json
generated
6
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "bonch-md-client",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@material/material-color-utilities": "^0.4.0",
|
||||
"axios": "^1.15.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
@@ -900,6 +901,11 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@material/material-color-utilities": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.4.0.tgz",
|
||||
"integrity": "sha512-dlq6VExJReb8dhjj3a/yTigr3ncNwoFmL5Iy2ENtbDX03EmNeOEdZ+vsaGrj7RTuO+mB7L58II4LCsl4NpM8uw=="
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material/material-color-utilities": "^0.4.0",
|
||||
"axios": "^1.15.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Calendar,
|
||||
GraduationCap,
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
User,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Menu
|
||||
Menu,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { useDisplay } from '../hooks/useDisplay';
|
||||
@@ -60,9 +61,10 @@ const navItems: NavItem[] = [
|
||||
|
||||
export const Navigation: React.FC = () => {
|
||||
const { navMode } = useDisplay();
|
||||
const { isRailExpanded, toggleRail, badges } = useStore();
|
||||
const { isRailExpanded, toggleRail, badges, profile, reset } = useStore();
|
||||
const scrollDir = useScrollDirection();
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const toggleSubmenu = (label: string) => {
|
||||
setExpandedItems(prev =>
|
||||
@@ -70,11 +72,20 @@ export const Navigation: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
reset();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const renderBadge = (count?: number) => {
|
||||
if (!count) return null;
|
||||
return <span className="m3-badge">{count > 99 ? '99+' : count}</span>;
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
};
|
||||
|
||||
if (navMode === 'bar') {
|
||||
return (
|
||||
<nav className={`navigation-bar ${scrollDir === 'down' ? 'hidden' : ''}`}>
|
||||
@@ -106,6 +117,18 @@ export const Navigation: React.FC = () => {
|
||||
{isExpanded && <span className="rail-title">Bonch</span>}
|
||||
</div>
|
||||
|
||||
{isExpanded && profile && (
|
||||
<div className="user-info">
|
||||
<div className="user-avatar">
|
||||
{getInitials(profile.fullName)}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name">{profile.fullName}</div>
|
||||
<div className="user-group">{profile.group || 'БЕЗ ГРУППЫ'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rail-items">
|
||||
{navItems.map((item) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
@@ -151,6 +174,15 @@ export const Navigation: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ marginTop: 'auto', padding: '12px' }}>
|
||||
<button onClick={handleLogout} className="m3-button" style={{ width: '100%', justifyContent: 'flex-start' }}>
|
||||
<LogOut size={20} style={{ marginRight: '12px' }} />
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
56
src/components/TextField.tsx
Normal file
56
src/components/TextField.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface TextFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
error?: string;
|
||||
supportingText?: string;
|
||||
}
|
||||
|
||||
export const TextField: React.FC<TextFieldProps> = ({
|
||||
label,
|
||||
error,
|
||||
supportingText,
|
||||
value,
|
||||
onFocus,
|
||||
onBlur,
|
||||
required,
|
||||
...props
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const isFilled = value !== undefined && value !== '';
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(e);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`m3-text-field-container ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${isFilled ? 'filled' : ''}`}>
|
||||
<div className="m3-text-field-field">
|
||||
<input
|
||||
{...props}
|
||||
value={value}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="m3-text-field-input"
|
||||
placeholder=" " // Keep empty for :placeholder-shown trick
|
||||
/>
|
||||
<label className="m3-text-field-label">
|
||||
{label}{required && ' *'}
|
||||
</label>
|
||||
<div className="m3-text-field-active-indicator" />
|
||||
<div className="m3-text-field-state-layer" />
|
||||
</div>
|
||||
{(error || supportingText) && (
|
||||
<div className="m3-text-field-supporting-text">
|
||||
{error ? `Ошибка: ${error}` : supportingText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,48 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { generateM3Scheme, applyM3CSSVars } from '../theme/dynamicTheme';
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { theme, seedColor } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
// Set Theme Class
|
||||
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
const scheme = generateM3Scheme(seedColor, isDark);
|
||||
applyM3CSSVars(scheme);
|
||||
|
||||
const root = document.documentElement;
|
||||
if (isDark) {
|
||||
root.classList.add('dark');
|
||||
root.style.colorScheme = 'dark';
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
root.style.colorScheme = 'light';
|
||||
}
|
||||
}, [theme, seedColor]);
|
||||
|
||||
// Set Primary Color (Seed)
|
||||
root.style.setProperty('--md-sys-color-primary-base', seedColor);
|
||||
// Handle system theme changes
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
// Simplification: In a real app we'd use material-color-utilities here.
|
||||
// For now we just use the seed color as primary.
|
||||
root.style.setProperty('--md-sys-color-primary', seedColor);
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
const isDark = mediaQuery.matches;
|
||||
const scheme = generateM3Scheme(seedColor, isDark);
|
||||
applyM3CSSVars(scheme);
|
||||
|
||||
const root = document.documentElement;
|
||||
if (isDark) {
|
||||
root.classList.add('dark');
|
||||
root.style.colorScheme = 'dark';
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
root.style.colorScheme = 'light';
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme, seedColor]);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { TextField } from '../components/TextField';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const { apiDomain, apiKey, isMockMode, setApiDomain, setApiKey, setMockMode } = useStore();
|
||||
@@ -15,75 +16,73 @@ export const Login: React.FC = () => {
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Basic validation
|
||||
if (!isMockMode && (!apiDomain || !apiKey)) {
|
||||
throw new Error('Please fill all fields');
|
||||
throw new Error('Заполните все поля');
|
||||
}
|
||||
|
||||
if (isMockMode && !apiKey) {
|
||||
setApiKey('mock-session');
|
||||
}
|
||||
|
||||
// Check health
|
||||
await api.checkHealth();
|
||||
|
||||
// If health check passes, try to get profile
|
||||
await api.getProfile();
|
||||
|
||||
navigate('/profile');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || err.message || 'Connection failed');
|
||||
setError(err.response?.data?.message || err.message || 'Ошибка подключения');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '400px', margin: '100px auto', padding: '20px' }}>
|
||||
<h1 style={{ marginBottom: '24px', textAlign: 'center' }}>Bonch Client</h1>
|
||||
<div style={{ maxWidth: '400px', margin: '100px auto', padding: '24px' }}>
|
||||
<h1 style={{ marginBottom: '32px', textAlign: 'center', fontWeight: 400 }}>Bonch Client</h1>
|
||||
<form onSubmit={handleLogin}>
|
||||
<input
|
||||
type="text"
|
||||
className="m3-text-field"
|
||||
placeholder="API Domain (e.g. https://api.example.com)"
|
||||
<TextField
|
||||
label="API Домен"
|
||||
value={apiDomain}
|
||||
onChange={(e) => setApiDomain(e.target.value)}
|
||||
disabled={isMockMode}
|
||||
required={!isMockMode}
|
||||
supportingText="Например: http://localhost:3000"
|
||||
/>
|
||||
<input
|
||||
|
||||
<TextField
|
||||
type="password"
|
||||
className="m3-text-field"
|
||||
placeholder="API Key"
|
||||
label="API Ключ"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={isMockMode}
|
||||
required={!isMockMode}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: '16px', display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ marginBottom: '24px', display: 'flex', alignItems: 'center', gap: '12px', padding: '0 4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mockMode"
|
||||
checked={isMockMode}
|
||||
onChange={(e) => setMockMode(e.target.checked)}
|
||||
style={{ marginRight: '8px' }}
|
||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||
/>
|
||||
<label htmlFor="mockMode" style={{ fontSize: '14px', cursor: 'pointer' }}>
|
||||
Enable Mock Mode (UI Only)
|
||||
<label htmlFor="mockMode" style={{ fontSize: '14px', cursor: 'pointer', userSelect: 'none' }}>
|
||||
Режим отладки (UI Only)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: 'var(--md-sys-color-error)', marginBottom: '16px' }}>
|
||||
<p style={{ color: 'var(--md-sys-color-error)', marginBottom: '16px', fontSize: '14px' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="m3-button m3-button-filled"
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: '100%', height: '48px' }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Connecting...' : 'Login'}
|
||||
{loading ? 'Подключение...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { api } from '../api/client';
|
||||
import type { Profile as ProfileType } from '../types/api';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { User, Mail, GraduationCap, Users, Calendar, GraduationCap as GradesIcon, MessageSquare, Settings } from 'lucide-react';
|
||||
import { useStore } from '../store/useStore';
|
||||
|
||||
export const Profile: React.FC = () => {
|
||||
const [profile, setProfile] = useState<ProfileType | null>(null);
|
||||
const [profileData, setProfileData] = useState<ProfileType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const { setProfile } = useStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -15,6 +17,7 @@ export const Profile: React.FC = () => {
|
||||
try {
|
||||
const data = await api.getProfile();
|
||||
setProfile(data);
|
||||
setProfileData(data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || err.message || 'Failed to load profile');
|
||||
if (err.response?.status === 401) {
|
||||
@@ -26,7 +29,7 @@ export const Profile: React.FC = () => {
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
}, [navigate]);
|
||||
}, [navigate, setProfile]);
|
||||
|
||||
if (loading) return <div style={{ padding: '20px', textAlign: 'center' }}>Loading profile...</div>;
|
||||
if (error) return (
|
||||
@@ -45,42 +48,42 @@ export const Profile: React.FC = () => {
|
||||
<h1>Profile</h1>
|
||||
</div>
|
||||
|
||||
{profile && (
|
||||
{profileData && (
|
||||
<div className="m3-card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<User size={24} style={{ marginRight: '16px', color: 'var(--md-sys-color-primary)' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.7 }}>Full Name</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 500 }}>{profile.fullName}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 500 }}>{profileData.fullName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.email && (
|
||||
{profileData.email && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<Mail size={24} style={{ marginRight: '16px', color: 'var(--md-sys-color-primary)' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.7 }}>Email</div>
|
||||
<div>{profile.email}</div>
|
||||
<div>{profileData.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.group && (
|
||||
{profileData.group && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<Users size={24} style={{ marginRight: '16px', color: 'var(--md-sys-color-primary)' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.7 }}>Group</div>
|
||||
<div>{profile.group}</div>
|
||||
<div>{profileData.group}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.faculty && (
|
||||
{profileData.faculty && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<GraduationCap size={24} style={{ marginRight: '16px', color: 'var(--md-sys-color-primary)' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.7 }}>Faculty</div>
|
||||
<div>{profile.faculty}</div>
|
||||
<div>{profileData.faculty}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { Palette, Moon, Sun, Monitor, LogOut } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TextField } from '../components/TextField';
|
||||
|
||||
const colors = [
|
||||
const presetColors = [
|
||||
{ name: 'Blue', value: '#0061a4' },
|
||||
{ name: 'Red', value: '#ba1a1a' },
|
||||
{ name: 'Green', value: '#006d3a' },
|
||||
@@ -13,6 +14,7 @@ const colors = [
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const { theme, setTheme, seedColor, setSeedColor, reset } = useStore();
|
||||
const [customColor, setCustomColor] = useState(seedColor);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -20,76 +22,134 @@ export const Settings: React.FC = () => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
setCustomColor(color);
|
||||
if (/^#[0-9A-F]{6}$/i.test(color)) {
|
||||
setSeedColor(color);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
|
||||
<h1 style={{ marginBottom: '24px' }}>Settings</h1>
|
||||
<h1 style={{ marginBottom: '32px' }}>Настройки</h1>
|
||||
|
||||
<section style={{ marginBottom: '32px' }}>
|
||||
<h2 style={{ fontSize: '18px', marginBottom: '16px', display: 'flex', alignItems: 'center' }}>
|
||||
<Palette size={20} style={{ marginRight: '12px' }} />
|
||||
Theme
|
||||
<section style={{ marginBottom: '40px' }}>
|
||||
<h2 style={{ fontSize: '18px', marginBottom: '16px', display: 'flex', alignItems: 'center', fontWeight: 500 }}>
|
||||
<Monitor size={20} style={{ marginRight: '12px' }} />
|
||||
Тема оформления
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`m3-button m3-button-tonal ${theme === 'light' ? 'active' : ''}`}
|
||||
style={{ border: theme === 'light' ? '2px solid var(--md-sys-color-primary)' : 'none' }}
|
||||
style={{
|
||||
border: theme === 'light' ? '2px solid var(--md-sys-color-primary)' : 'none',
|
||||
height: '56px'
|
||||
}}
|
||||
>
|
||||
<Sun size={18} style={{ marginRight: '8px' }} />
|
||||
Light
|
||||
Светлая
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`m3-button m3-button-tonal ${theme === 'dark' ? 'active' : ''}`}
|
||||
style={{ border: theme === 'dark' ? '2px solid var(--md-sys-color-primary)' : 'none' }}
|
||||
style={{
|
||||
border: theme === 'dark' ? '2px solid var(--md-sys-color-primary)' : 'none',
|
||||
height: '56px'
|
||||
}}
|
||||
>
|
||||
<Moon size={18} style={{ marginRight: '8px' }} />
|
||||
Dark
|
||||
Тёмная
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('system')}
|
||||
className={`m3-button m3-button-tonal ${theme === 'system' ? 'active' : ''}`}
|
||||
style={{ border: theme === 'system' ? '2px solid var(--md-sys-color-primary)' : 'none' }}
|
||||
style={{
|
||||
border: theme === 'system' ? '2px solid var(--md-sys-color-primary)' : 'none',
|
||||
height: '56px'
|
||||
}}
|
||||
>
|
||||
<Monitor size={18} style={{ marginRight: '8px' }} />
|
||||
System
|
||||
Системная
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 style={{ fontSize: '18px', marginBottom: '16px', display: 'flex', alignItems: 'center' }}>
|
||||
<section style={{ marginBottom: '40px' }}>
|
||||
<h2 style={{ fontSize: '18px', marginBottom: '16px', display: 'flex', alignItems: 'center', fontWeight: 500 }}>
|
||||
<Palette size={20} style={{ marginRight: '12px' }} />
|
||||
Accent Color
|
||||
Акцентный цвет (Dynamic Color)
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
||||
{colors.map((c) => (
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginBottom: '24px' }}>
|
||||
{presetColors.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
onClick={() => setSeedColor(c.value)}
|
||||
onClick={() => handleColorChange(c.value)}
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '24px',
|
||||
backgroundColor: c.value,
|
||||
border: seedColor === c.value ? '4px solid var(--md-sys-color-on-surface)' : 'none',
|
||||
border: seedColor.toLowerCase() === c.value.toLowerCase() ? '4px solid var(--md-sys-color-on-surface)' : 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
title={c.name}
|
||||
/>
|
||||
))}
|
||||
<div style={{ position: 'relative', width: '48px', height: '48px' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={seedColor}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="HEX код цвета"
|
||||
value={customColor}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
placeholder="#0061A4"
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '16px', padding: '16px', borderRadius: '12px', backgroundColor: 'var(--md-sys-color-surface-variant)' }}>
|
||||
<div style={{ fontSize: '14px', marginBottom: '12px', fontWeight: 500 }}>Превью палитры:</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{['primary', 'primary-container', 'secondary', 'secondary-container', 'tertiary', 'tertiary-container'].map(role => (
|
||||
<div
|
||||
key={role}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '40px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: `var(--md-sys-color-${role})`,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)'
|
||||
}}
|
||||
title={role}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: '48px', borderTop: '1px solid var(--md-sys-color-outline)', paddingTop: '24px' }}>
|
||||
<section style={{ marginTop: '48px', borderTop: '1px solid var(--md-sys-color-outline-variant)', paddingTop: '24px' }}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="m3-button m3-button-tonal"
|
||||
style={{ color: 'var(--md-sys-color-error)', width: '100%' }}
|
||||
style={{ color: 'var(--md-sys-color-error)', width: '100%', height: '48px' }}
|
||||
>
|
||||
<LogOut size={18} style={{ marginRight: '8px' }} />
|
||||
Logout from Session
|
||||
<LogOut size={18} style={{ marginRight: '12px' }} />
|
||||
Выйти из системы
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Profile } from '../types/api';
|
||||
|
||||
interface AppState {
|
||||
apiDomain: string;
|
||||
@@ -8,6 +9,7 @@ interface AppState {
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
seedColor: string;
|
||||
isRailExpanded: boolean;
|
||||
profile: Profile | null;
|
||||
badges: {
|
||||
messages: number;
|
||||
};
|
||||
@@ -16,6 +18,7 @@ interface AppState {
|
||||
setMockMode: (isMock: boolean) => void;
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => void;
|
||||
setSeedColor: (color: string) => void;
|
||||
setProfile: (profile: Profile | null) => void;
|
||||
toggleRail: () => void;
|
||||
setRailExpanded: (expanded: boolean) => void;
|
||||
setBadge: (key: keyof AppState['badges'], count: number) => void;
|
||||
@@ -31,20 +34,22 @@ export const useStore = create<AppState>()(
|
||||
theme: 'system',
|
||||
seedColor: '#0061a4',
|
||||
isRailExpanded: true,
|
||||
profile: null,
|
||||
badges: {
|
||||
messages: 3, // Mock value
|
||||
messages: 3,
|
||||
},
|
||||
setApiDomain: (apiDomain) => set({ apiDomain }),
|
||||
setApiKey: (apiKey) => set({ apiKey }),
|
||||
setMockMode: (isMockMode) => set({ isMockMode }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setSeedColor: (seedColor) => set({ seedColor }),
|
||||
setProfile: (profile) => set({ profile }),
|
||||
toggleRail: () => set((state) => ({ isRailExpanded: !state.isRailExpanded })),
|
||||
setRailExpanded: (isRailExpanded) => set({ isRailExpanded }),
|
||||
setBadge: (key, count) => set((state) => ({
|
||||
badges: { ...state.badges, [key]: count }
|
||||
})),
|
||||
reset: () => set({ apiKey: '' }),
|
||||
reset: () => set({ apiKey: '', profile: null }),
|
||||
}),
|
||||
{
|
||||
name: 'bonch-md-storage',
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
--md-sys-color-surface-variant: #dfe2eb;
|
||||
--md-sys-color-on-surface-variant: #43474e;
|
||||
--md-sys-color-outline: #73777f;
|
||||
--md-sys-color-outline-variant: #c3c7cf;
|
||||
--md-sys-color-error: #ba1a1a;
|
||||
--md-sys-color-on-error: #ffffff;
|
||||
--md-sys-color-background: #fdfcff;
|
||||
--md-sys-color-on-background: #1a1c1e;
|
||||
|
||||
--nav-rail-width: 80px;
|
||||
--nav-rail-expanded-width: 280px;
|
||||
@@ -33,7 +37,11 @@
|
||||
--md-sys-color-surface-variant: #43474e;
|
||||
--md-sys-color-on-surface-variant: #c3c7cf;
|
||||
--md-sys-color-outline: #8d9199;
|
||||
--md-sys-color-outline-variant: #43474e;
|
||||
--md-sys-color-error: #ffb4ab;
|
||||
--md-sys-color-on-error: #690005;
|
||||
--md-sys-color-background: #1a1c1e;
|
||||
--md-sys-color-on-background: #e2e2e6;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -44,8 +52,8 @@
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-background);
|
||||
color: var(--md-sys-color-on-background);
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
@@ -57,7 +65,7 @@ body {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background-color: var(--md-sys-color-error);
|
||||
color: white;
|
||||
color: var(--md-sys-color-on-error);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
min-width: 16px;
|
||||
@@ -80,10 +88,13 @@ body {
|
||||
border: none;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
background-color: rgba(var(--md-sys-color-on-surface-variant-rgb, 67, 71, 78), 0.08);
|
||||
}
|
||||
|
||||
.m3-button {
|
||||
@@ -97,10 +108,11 @@ body {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.1px;
|
||||
transition: box-shadow 0.2s, background-color 0.2s;
|
||||
transition: all 0.2s;
|
||||
background-color: transparent;
|
||||
color: var(--md-sys-color-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m3-button-filled {
|
||||
@@ -108,11 +120,20 @@ body {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
.m3-button-filled:hover {
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
|
||||
}
|
||||
|
||||
.m3-button-tonal {
|
||||
background-color: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
.m3-button-outlined {
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.m3-card {
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
@@ -121,16 +142,98 @@ body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.m3-text-field {
|
||||
/* M3 Filled Text Field */
|
||||
|
||||
.m3-text-field-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline);
|
||||
}
|
||||
|
||||
.m3-text-field-field {
|
||||
position: relative;
|
||||
height: 56px;
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
border-radius: 4px 4px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m3-text-field-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px 16px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.m3-text-field-label {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 16px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.m3-text-field-container.focused .m3-text-field-label,
|
||||
.m3-text-field-container.filled .m3-text-field-label {
|
||||
top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.m3-text-field-active-indicator {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--md-sys-color-on-surface-variant);
|
||||
transition: height 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.m3-text-field-container.focused .m3-text-field-active-indicator {
|
||||
height: 2px;
|
||||
background-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.m3-text-field-state-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--md-sys-color-on-surface);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m3-text-field-field:hover .m3-text-field-state-layer {
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.m3-text-field-container.error .m3-text-field-label,
|
||||
.m3-text-field-container.error .m3-text-field-active-indicator {
|
||||
color: var(--md-sys-color-error);
|
||||
background-color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.m3-text-field-supporting-text {
|
||||
font-size: 12px;
|
||||
padding: 4px 16px 0;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.m3-text-field-container.error .m3-text-field-supporting-text {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
@@ -191,7 +294,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0;
|
||||
border-right: 1px solid var(--md-sys-color-outline);
|
||||
border-right: 1px solid var(--md-sys-color-outline-variant);
|
||||
z-index: 100;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow-x: hidden;
|
||||
@@ -216,6 +319,46 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-group {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.rail-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
85
src/theme/dynamicTheme.ts
Normal file
85
src/theme/dynamicTheme.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
argbFromHex,
|
||||
hexFromArgb,
|
||||
SchemeTonalSpot,
|
||||
Hct,
|
||||
MaterialDynamicColors,
|
||||
DynamicScheme,
|
||||
} from '@material/material-color-utilities';
|
||||
|
||||
export interface M3ColorScheme {
|
||||
primary: string;
|
||||
onPrimary: string;
|
||||
primaryContainer: string;
|
||||
onPrimaryContainer: string;
|
||||
secondary: string;
|
||||
onSecondary: string;
|
||||
secondaryContainer: string;
|
||||
onSecondaryContainer: string;
|
||||
tertiary: string;
|
||||
onTertiary: string;
|
||||
tertiaryContainer: string;
|
||||
onTertiaryContainer: string;
|
||||
surface: string;
|
||||
onSurface: string;
|
||||
surfaceVariant: string;
|
||||
onSurfaceVariant: string;
|
||||
background: string;
|
||||
onBackground: string;
|
||||
error: string;
|
||||
onError: string;
|
||||
errorContainer: string;
|
||||
onErrorContainer: string;
|
||||
outline: string;
|
||||
outlineVariant: string;
|
||||
}
|
||||
|
||||
export function generateM3Scheme(
|
||||
seedHex: string,
|
||||
isDark: boolean,
|
||||
contrastLevel = 0.0
|
||||
): M3ColorScheme {
|
||||
const sourceColorArgb = argbFromHex(seedHex);
|
||||
const sourceHct = Hct.fromInt(sourceColorArgb);
|
||||
|
||||
// SchemeTonalSpot is the standard M3 scheme
|
||||
const scheme: DynamicScheme = new SchemeTonalSpot(sourceHct, isDark, contrastLevel);
|
||||
|
||||
const resolve = (role: any) => hexFromArgb(role.getArgb(scheme));
|
||||
|
||||
return {
|
||||
primary: resolve(MaterialDynamicColors.primary),
|
||||
onPrimary: resolve(MaterialDynamicColors.onPrimary),
|
||||
primaryContainer: resolve(MaterialDynamicColors.primaryContainer),
|
||||
onPrimaryContainer: resolve(MaterialDynamicColors.onPrimaryContainer),
|
||||
secondary: resolve(MaterialDynamicColors.secondary),
|
||||
onSecondary: resolve(MaterialDynamicColors.onSecondary),
|
||||
secondaryContainer: resolve(MaterialDynamicColors.secondaryContainer),
|
||||
onSecondaryContainer: resolve(MaterialDynamicColors.onSecondaryContainer),
|
||||
tertiary: resolve(MaterialDynamicColors.tertiary),
|
||||
onTertiary: resolve(MaterialDynamicColors.onTertiary),
|
||||
tertiaryContainer: resolve(MaterialDynamicColors.tertiaryContainer),
|
||||
onTertiaryContainer: resolve(MaterialDynamicColors.onTertiaryContainer),
|
||||
surface: resolve(MaterialDynamicColors.surface),
|
||||
onSurface: resolve(MaterialDynamicColors.onSurface),
|
||||
surfaceVariant: resolve(MaterialDynamicColors.surfaceVariant),
|
||||
onSurfaceVariant: resolve(MaterialDynamicColors.onSurfaceVariant),
|
||||
background: resolve(MaterialDynamicColors.background),
|
||||
onBackground: resolve(MaterialDynamicColors.onBackground),
|
||||
error: resolve(MaterialDynamicColors.error),
|
||||
onError: resolve(MaterialDynamicColors.onError),
|
||||
errorContainer: resolve(MaterialDynamicColors.errorContainer),
|
||||
onErrorContainer: resolve(MaterialDynamicColors.onErrorContainer),
|
||||
outline: resolve(MaterialDynamicColors.outline),
|
||||
outlineVariant: resolve(MaterialDynamicColors.outlineVariant),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyM3CSSVars(scheme: M3ColorScheme): void {
|
||||
const root = document.documentElement;
|
||||
Object.entries(scheme).forEach(([key, value]) => {
|
||||
// camelCase → kebab-case
|
||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
root.style.setProperty(`--md-sys-color-${cssKey}`, value);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user