feat(auth): split-screen login with geometric pattern
Redesign the login page into a two-column layout: a decorative geometric line pattern on the left (hidden on mobile) and the form on the right with the PVEmanager logo on top and a footer with a language switcher + copyright. Add a password show/hide toggle and a "forgot password" link. Login logic (username/password) is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
47
frontend/src/features/auth/AuthPattern.tsx
Normal file
47
frontend/src/features/auth/AuthPattern.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Декоративный геометрический паттерн для левой панели страницы входа.
|
||||||
|
* Тонкие полупрозрачные линии (круги, квадраты, треугольники, ломаные),
|
||||||
|
* бесшовно повторяющиеся через SVG <pattern>. Цвет линий — currentColor.
|
||||||
|
*/
|
||||||
|
export default function AuthPattern({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id="auth-geo"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeOpacity="0.5"
|
||||||
|
>
|
||||||
|
{/* сетка-рамки */}
|
||||||
|
<rect x="0.5" y="0.5" width="199" height="199" />
|
||||||
|
{/* круг */}
|
||||||
|
<circle cx="100" cy="60" r="46" />
|
||||||
|
{/* квадрат со срезом-диагональю */}
|
||||||
|
<rect x="20" y="120" width="56" height="56" />
|
||||||
|
<line x1="20" y1="176" x2="76" y2="120" />
|
||||||
|
{/* треугольники из диагоналей */}
|
||||||
|
<path d="M120 120 L176 120 L176 176 Z" />
|
||||||
|
<path d="M120 176 L120 120 L176 176 Z" strokeOpacity="0.25" />
|
||||||
|
{/* ломаная линия */}
|
||||||
|
<path d="M0 40 L40 40 L40 80 L0 80" strokeOpacity="0.35" />
|
||||||
|
</g>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#auth-geo)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const { login, isLoading, error, clearError } = useAuthStore();
|
const { login, isLoading, error, clearError } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -25,11 +26,9 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-6">
|
||||||
<CardHeader className="text-center">
|
<h1 className="text-2xl font-semibold">{t('login.title', 'Sign in to your account')}</h1>
|
||||||
<CardTitle className="text-lg">{t('login.title', 'Sign in to your account')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
@@ -56,9 +55,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">{t('login.password', 'Password')}</Label>
|
<Label htmlFor="password">{t('login.password', 'Password')}</Label>
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPassword(e.target.value);
|
setPassword(e.target.value);
|
||||||
@@ -66,11 +66,22 @@ export default function LoginPage() {
|
|||||||
}}
|
}}
|
||||||
placeholder={t('login.password_placeholder', 'Enter password')}
|
placeholder={t('login.password_placeholder', 'Enter password')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
className="pr-9"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<div className="flex items-center gap-4 pt-1">
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
@@ -80,8 +91,14 @@ export default function LoginPage() {
|
|||||||
t('login.sign_in', 'Sign in')
|
t('login.sign_in', 'Sign in')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{t('login.forgot_password', 'Forgot password')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,58 @@
|
|||||||
import { Outlet } from 'react-router';
|
import { Outlet } from 'react-router';
|
||||||
import { Monitor } from 'lucide-react';
|
import { Monitor } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import AuthPattern from '@/features/auth/AuthPattern';
|
||||||
|
|
||||||
|
function LanguageSwitcher() {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||||
|
aria-label="Language"
|
||||||
|
className="cursor-pointer rounded-md bg-transparent py-1 pr-1 text-sm text-text-secondary outline-none transition-colors hover:text-text-primary focus-visible:text-text-primary"
|
||||||
|
>
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthLayout() {
|
export default function AuthLayout() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
<div className="grid min-h-screen bg-surface-0 lg:grid-cols-[1.4fr_1fr]">
|
||||||
<div className="w-full max-w-sm">
|
{/* Левая декоративная панель */}
|
||||||
{/* Logo */}
|
<div className="relative hidden overflow-hidden bg-surface-1 text-primary/60 lg:block">
|
||||||
<div className="mb-8 flex flex-col items-center gap-3">
|
<AuthPattern className="absolute inset-0 h-full w-full" />
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
|
|
||||||
<Monitor className="h-6 w-6 text-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-xl font-semibold">PVEmanager</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Правая панель с формой */}
|
||||||
|
<div className="flex min-h-screen flex-col px-6 py-8 sm:px-12">
|
||||||
|
{/* Логотип */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary">
|
||||||
|
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-semibold">PVEmanager</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Форма по центру */}
|
||||||
|
<div className="flex flex-1 items-center">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Футер */}
|
||||||
|
<div className="flex items-center gap-4 text-sm text-text-secondary">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<span>PVEmanager © {year}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,9 @@
|
|||||||
"password_placeholder": "Enter password",
|
"password_placeholder": "Enter password",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"signing_in": "Signing in...",
|
"signing_in": "Signing in...",
|
||||||
|
"forgot_password": "Forgot password",
|
||||||
|
"show_password": "Show password",
|
||||||
|
"hide_password": "Hide password",
|
||||||
"logout": "Logout"
|
"logout": "Logout"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|||||||
@@ -66,6 +66,9 @@
|
|||||||
"password_placeholder": "Введите пароль",
|
"password_placeholder": "Введите пароль",
|
||||||
"sign_in": "Войти",
|
"sign_in": "Войти",
|
||||||
"signing_in": "Входим...",
|
"signing_in": "Входим...",
|
||||||
|
"forgot_password": "Я не помню пароль",
|
||||||
|
"show_password": "Показать пароль",
|
||||||
|
"hide_password": "Скрыть пароль",
|
||||||
"logout": "Выход"
|
"logout": "Выход"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|||||||
Reference in New Issue
Block a user