diff --git a/package-lock.json b/package-lock.json index 56cf760..1d2a224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 166f157..0e0bad2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index fee1d9b..7948e02 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -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([]); + 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 {count > 99 ? '99+' : count}; }; + const getInitials = (name: string) => { + return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); + }; + if (navMode === 'bar') { return ( ); }; diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx new file mode 100644 index 0000000..8db8e08 --- /dev/null +++ b/src/components/TextField.tsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; + +interface TextFieldProps extends React.InputHTMLAttributes { + label: string; + error?: string; + supportingText?: string; +} + +export const TextField: React.FC = ({ + label, + error, + supportingText, + value, + onFocus, + onBlur, + required, + ...props +}) => { + const [isFocused, setIsFocused] = useState(false); + const isFilled = value !== undefined && value !== ''; + + const handleFocus = (e: React.FocusEvent) => { + setIsFocused(true); + onFocus?.(e); + }; + + const handleBlur = (e: React.FocusEvent) => { + setIsFocused(false); + onBlur?.(e); + }; + + return ( +
+
+ + +
+
+
+ {(error || supportingText) && ( +
+ {error ? `Ошибка: ${error}` : supportingText} +
+ )} +
+ ); +}; diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 1721e21..54ab7b0 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -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); - - // 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); + // Handle system theme changes + useEffect(() => { + if (theme !== 'system') return; + + 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}; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index b353229..a5c5597 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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 ( -
-

Bonch Client

+
+

Bonch Client

- setApiDomain(e.target.value)} disabled={isMockMode} + required={!isMockMode} + supportingText="Например: http://localhost:3000" /> - setApiKey(e.target.value)} disabled={isMockMode} + required={!isMockMode} /> -
+
setMockMode(e.target.checked)} - style={{ marginRight: '8px' }} + style={{ width: '18px', height: '18px', cursor: 'pointer' }} /> -
{error && ( -

+

{error}

)} +
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index ee00b0c..1d8661c 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -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(null); + const [profileData, setProfileData] = useState(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
Loading profile...
; if (error) return ( @@ -45,42 +48,42 @@ export const Profile: React.FC = () => {

Profile

- {profile && ( + {profileData && (
Full Name
-
{profile.fullName}
+
{profileData.fullName}
- {profile.email && ( + {profileData.email && (
Email
-
{profile.email}
+
{profileData.email}
)} - {profile.group && ( + {profileData.group && (
Group
-
{profile.group}
+
{profileData.group}
)} - {profile.faculty && ( + {profileData.faculty && (
Faculty
-
{profile.faculty}
+
{profileData.faculty}
)} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 86b476b..b5244a4 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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 (
-

Settings

+

Настройки

-
-

- - Theme +
+

+ + Тема оформления

-
-

+
+

- Accent Color + Акцентный цвет (Dynamic Color)

-
- {colors.map((c) => ( + +
+ {presetColors.map((c) => (
+ + handleColorChange(e.target.value)} + placeholder="#0061A4" + /> + +
+
Превью палитры:
+
+ {['primary', 'primary-container', 'secondary', 'secondary-container', 'tertiary', 'tertiary-container'].map(role => ( +
+ ))} +
-
+

diff --git a/src/store/useStore.ts b/src/store/useStore.ts index 03cdd31..587278a 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -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()( 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', diff --git a/src/styles/globals.css b/src/styles/globals.css index d811772..d831b2b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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; diff --git a/src/theme/dynamicTheme.ts b/src/theme/dynamicTheme.ts new file mode 100644 index 0000000..41b6655 --- /dev/null +++ b/src/theme/dynamicTheme.ts @@ -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); + }); +}