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 { useNavigate } from 'react-router';
import { Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth-store';
import { useTranslation } from 'react-i18next';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { login, isLoading, error, clearError } = useAuthStore();
const navigate = useNavigate();
const { t } = useTranslation();
@@ -25,11 +26,9 @@ export default function LoginPage() {
};
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-lg">{t('login.title', 'Sign in to your account')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{t('login.title', 'Sign in to your account')}</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<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">
<Label htmlFor="password">{t('login.password', 'Password')}</Label>
<div className="relative">
<Input
id="password"
type="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => {
setPassword(e.target.value);
@@ -66,11 +66,22 @@ export default function LoginPage() {
}}
placeholder={t('login.password_placeholder', 'Enter password')}
autoComplete="current-password"
className="pr-9"
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>
<Button type="submit" className="w-full" disabled={isLoading}>
<div className="flex items-center gap-4 pt-1">
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
<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')
)}
</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>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,20 +1,58 @@
import { Outlet } from 'react-router';
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() {
const year = new Date().getFullYear();
return (
<div className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="mb-8 flex flex-col items-center gap-3">
<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 className="grid min-h-screen bg-surface-0 lg:grid-cols-[1.4fr_1fr]">
{/* Левая декоративная панель */}
<div className="relative hidden overflow-hidden bg-surface-1 text-primary/60 lg:block">
<AuthPattern className="absolute inset-0 h-full w-full" />
</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 />
</div>
</div>
{/* Футер */}
<div className="flex items-center gap-4 text-sm text-text-secondary">
<LanguageSwitcher />
<span>PVEmanager © {year}</span>
</div>
</div>
</div>
);
}

View File

@@ -66,6 +66,9 @@
"password_placeholder": "Enter password",
"sign_in": "Sign in",
"signing_in": "Signing in...",
"forgot_password": "Forgot password",
"show_password": "Show password",
"hide_password": "Hide password",
"logout": "Logout"
},
"dashboard": {

View File

@@ -66,6 +66,9 @@
"password_placeholder": "Введите пароль",
"sign_in": "Войти",
"signing_in": "Входим...",
"forgot_password": "Я не помню пароль",
"show_password": "Показать пароль",
"hide_password": "Скрыть пароль",
"logout": "Выход"
},
"dashboard": {