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:
2026-04-11 01:16:36 +03:00
parent db0b93b007
commit b71076450c
11 changed files with 493 additions and 82 deletions

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>