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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user