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:
2026-06-26 03:05:18 +05:00
parent 0510c1739d
commit 89962a7f20
5 changed files with 150 additions and 42 deletions

View 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>
);
}

View File

@@ -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,40 +26,39 @@ 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">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2"> <form onSubmit={handleSubmit} className="space-y-4">
<Label htmlFor="username">{t('login.username', 'Username')}</Label> {error && (
<Input <div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
id="username" {error}
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
clearError();
}}
placeholder={t('login.username_placeholder', 'Enter username')}
autoComplete="username"
autoFocus
required
/>
</div> </div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">{t('login.password', 'Password')}</Label> <Label htmlFor="username">{t('login.username', 'Username')}</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
clearError();
}}
placeholder={t('login.username_placeholder', 'Enter username')}
autoComplete="username"
autoFocus
required
/>
</div>
<div className="space-y-2">
<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>
</form> <button
</CardContent> type="button"
</Card> className="text-sm font-medium text-primary transition-opacity hover:opacity-80"
>
{t('login.forgot_password', 'Forgot password')}
</button>
</div>
</form>
</div>
); );
} }

View File

@@ -1,19 +1,57 @@
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"> </div>
<Monitor className="h-6 w-6 text-primary-foreground" />
{/* Правая панель с формой */}
<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> </div>
<h1 className="text-xl font-semibold">PVEmanager</h1> <span className="text-lg font-semibold">PVEmanager</span>
</div> </div>
<Outlet /> {/* Форма по центру */}
<div className="flex flex-1 items-center">
<div className="w-full max-w-sm">
<Outlet />
</div>
</div>
{/* Футер */}
<div className="flex items-center gap-4 text-sm text-text-secondary">
<LanguageSwitcher />
<span>PVEmanager © {year}</span>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -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": {

View File

@@ -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": {