+мобильная версия
This commit is contained in:
parent
378f039bb3
commit
08036f52f6
@ -1,6 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isAuthenticated, loginUser, seedDemoUser } from "../../lib/auth";
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
seedDemoUser();
|
||||
if (isAuthenticated()) {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!login.trim() || !password.trim()) {
|
||||
setError("Заполните логин и пароль");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = loginUser(login, password);
|
||||
if (!result.ok) {
|
||||
setError(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white text-gray-900">
|
||||
<div className="mx-auto flex min-h-screen max-w-4xl flex-col justify-center px-6 py-12 sm:px-10">
|
||||
@ -12,7 +47,7 @@ export default function Login() {
|
||||
Используйте телефон или почту, чтобы продолжить.
|
||||
</p>
|
||||
|
||||
<form className="space-y-4">
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="login">
|
||||
Телефон или почта
|
||||
@ -22,6 +57,8 @@ export default function Login() {
|
||||
name="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={login}
|
||||
onChange={(event) => setLogin(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="example@mail.com"
|
||||
/>
|
||||
@ -35,10 +72,13 @@ export default function Login() {
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
@ -64,6 +104,7 @@ export default function Login() {
|
||||
Создать аккаунт
|
||||
</Link>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-xs text-gray-500">Демо вход: demo@corp.local / 123456</p>
|
||||
<p className="mt-6 text-center text-xs text-gray-500">
|
||||
Продолжая, вы соглашаетесь с условиями использования и подтверждаете ознакомление с политикой конфиденциальности.
|
||||
</p>
|
||||
|
||||
@ -1,6 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isAuthenticated, loginUser, registerUser, seedDemoUser } from "../../lib/auth";
|
||||
|
||||
export default function Register() {
|
||||
const router = useRouter();
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
seedDemoUser();
|
||||
if (isAuthenticated()) {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!firstName.trim() || !lastName.trim() || !login.trim() || !password.trim() || !confirmPassword.trim()) {
|
||||
setError("Заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Пароль должен содержать минимум 6 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accepted) {
|
||||
setError("Подтвердите согласие с условиями использования");
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = registerUser({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
login: login.trim(),
|
||||
password,
|
||||
});
|
||||
|
||||
if (!registration.ok) {
|
||||
setError(registration.message);
|
||||
return;
|
||||
}
|
||||
|
||||
loginUser(login.trim(), password);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white text-gray-900">
|
||||
<div className="mx-auto flex min-h-screen max-w-4xl flex-col justify-center px-6 py-12 sm:px-10">
|
||||
@ -12,7 +73,7 @@ export default function Register() {
|
||||
Создайте новый аккаунт, указав имя и контактные данные.
|
||||
</p>
|
||||
|
||||
<form className="space-y-4">
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="firstName">
|
||||
@ -22,6 +83,8 @@ export default function Register() {
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(event) => setFirstName(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Иван"
|
||||
/>
|
||||
@ -34,6 +97,8 @@ export default function Register() {
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(event) => setLastName(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Иванов"
|
||||
/>
|
||||
@ -48,6 +113,8 @@ export default function Register() {
|
||||
name="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={login}
|
||||
onChange={(event) => setLogin(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="example@mail.com"
|
||||
/>
|
||||
@ -62,6 +129,8 @@ export default function Register() {
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
@ -75,6 +144,8 @@ export default function Register() {
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
@ -83,11 +154,15 @@ export default function Register() {
|
||||
<label className="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accepted}
|
||||
onChange={(event) => setAccepted(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
|
||||
/>
|
||||
Я согласен с условиями использования
|
||||
</label>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center rounded-lg bg-sky-400 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
|
||||
@ -1,9 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Building2, FileSignature, FileText, Home as HomeIcon, MessageSquare, Search, Users, Wallet } from "lucide-react";
|
||||
import {
|
||||
Bell,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Home as HomeIcon,
|
||||
LogOut,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Users,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { isAuthenticated, logoutUser } from "../lib/auth";
|
||||
|
||||
type AppShellProps = {
|
||||
children: ReactNode;
|
||||
@ -16,6 +31,15 @@ type PageHeaderProps = {
|
||||
eyebrow?: string;
|
||||
};
|
||||
|
||||
type HeaderProps = {
|
||||
onOpenLeftNav: () => void;
|
||||
onOpenCalendar: () => void;
|
||||
};
|
||||
|
||||
type LeftNavContentProps = {
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Лента", icon: HomeIcon },
|
||||
{ href: "/messages", label: "Сообщения", icon: MessageSquare },
|
||||
@ -36,35 +60,206 @@ const calendarWeeks = [
|
||||
["25", "26", "27", "28", "", "", ""],
|
||||
];
|
||||
|
||||
function Header() {
|
||||
function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
|
||||
const [isDesktopProfileMenuOpen, setIsDesktopProfileMenuOpen] = useState(false);
|
||||
const [isMobileProfileMenuOpen, setIsMobileProfileMenuOpen] = useState(false);
|
||||
|
||||
const notificationsRef = useRef<HTMLDivElement>(null);
|
||||
const desktopProfileRef = useRef<HTMLDivElement>(null);
|
||||
const mobileProfileRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
|
||||
const clickedOutsideNotifications = notificationsRef.current && !notificationsRef.current.contains(target);
|
||||
const clickedOutsideDesktopProfile = desktopProfileRef.current && !desktopProfileRef.current.contains(target);
|
||||
const clickedOutsideMobileProfile = mobileProfileRef.current && !mobileProfileRef.current.contains(target);
|
||||
|
||||
if (clickedOutsideNotifications) {
|
||||
setIsNotificationsOpen(false);
|
||||
}
|
||||
|
||||
if (clickedOutsideDesktopProfile) {
|
||||
setIsDesktopProfileMenuOpen(false);
|
||||
}
|
||||
|
||||
if (clickedOutsideMobileProfile) {
|
||||
setIsMobileProfileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsNotificationsOpen(false);
|
||||
setIsDesktopProfileMenuOpen(false);
|
||||
setIsMobileProfileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
document.addEventListener("keydown", onEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onClickOutside);
|
||||
document.removeEventListener("keydown", onEscape);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
setIsNotificationsOpen(false);
|
||||
setIsDesktopProfileMenuOpen(false);
|
||||
setIsMobileProfileMenuOpen(false);
|
||||
logoutUser();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-slate-100 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4 sm:px-8">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<img src="/logo.png" alt="Логотип" className="h-11 w-auto" />
|
||||
</Link>
|
||||
<div className="hidden flex-1 items-center justify-center md:flex">
|
||||
<div className="relative w-full max-w-xl">
|
||||
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Поиск"
|
||||
/>
|
||||
<header className="sticky top-0 z-40 border-b border-slate-100 bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-8">
|
||||
<div className="hidden h-14 items-center gap-4 lg:flex">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<img src="/logo.png" alt="Логотип" className="h-11 w-auto" />
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="relative w-full max-w-xl">
|
||||
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Поиск"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div ref={notificationsRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsNotificationsOpen((prev) => !prev);
|
||||
setIsDesktopProfileMenuOpen(false);
|
||||
}}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isNotificationsOpen}
|
||||
aria-label="Открыть уведомления"
|
||||
>
|
||||
<Bell size={18} className="text-gray-600" />
|
||||
</button>
|
||||
|
||||
{isNotificationsOpen ? (
|
||||
<div className="absolute right-0 top-12 z-40 w-56 rounded-xl bg-white p-3 text-sm text-gray-700 shadow-lg ring-1 ring-gray-100">
|
||||
новых уведомлений не
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div ref={desktopProfileRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsDesktopProfileMenuOpen((prev) => !prev);
|
||||
setIsNotificationsOpen(false);
|
||||
}}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isDesktopProfileMenuOpen}
|
||||
aria-label="Открыть меню профиля"
|
||||
>
|
||||
КМ
|
||||
</button>
|
||||
|
||||
{isDesktopProfileMenuOpen ? (
|
||||
<div className="absolute right-0 top-12 z-40 w-44 rounded-xl bg-white p-2 shadow-lg ring-1 ring-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1 sm:gap-2">
|
||||
<button className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100">
|
||||
<Bell size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<div className="ml-1 flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">КМ</div>
|
||||
|
||||
<div className="h-14 lg:hidden">
|
||||
<div className="relative flex h-full items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLeftNav}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
|
||||
aria-label="Открыть левое меню"
|
||||
>
|
||||
<Menu size={20} className="text-gray-700" />
|
||||
</button>
|
||||
|
||||
<Link href="/" className="absolute left-1/2 -translate-x-1/2">
|
||||
<img src="/logo.png" alt="Логотип" className="h-10 w-auto" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCalendar}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
|
||||
aria-label="Открыть календарь"
|
||||
>
|
||||
<CalendarDays size={20} className="text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pb-3 lg:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-10 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
placeholder="Поиск"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={mobileProfileRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobileProfileMenuOpen((prev) => !prev)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isMobileProfileMenuOpen}
|
||||
aria-label="Открыть меню профиля"
|
||||
>
|
||||
КМ
|
||||
</button>
|
||||
|
||||
{isMobileProfileMenuOpen ? (
|
||||
<div className="absolute right-0 top-12 z-40 w-44 rounded-xl bg-white p-2 shadow-lg ring-1 ring-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftNav() {
|
||||
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const navLinkClass = (href: string) =>
|
||||
@ -75,68 +270,80 @@ function LeftNav() {
|
||||
const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400");
|
||||
|
||||
return (
|
||||
<aside className="hidden w-64 flex-shrink-0 lg:block">
|
||||
<div className="sticky top-8 space-y-4">
|
||||
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">КМ</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">Константин Макаев</p>
|
||||
<p className="text-xs text-gray-500">Онлайн</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
{navItems.map(({ href, label, icon: Icon }) => (
|
||||
<Link key={href} href={href} className={navLinkClass(href)}>
|
||||
<Icon size={18} className={navIconClass(href)} />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">КМ</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">Константин Макаев</p>
|
||||
<p className="text-xs text-gray-500">Онлайн</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
{navItems.map(({ href, label, icon: Icon }) => (
|
||||
<Link key={href} href={href} className={navLinkClass(href)} onClick={onNavigate}>
|
||||
<Icon size={18} className={navIconClass(href)} />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftNav() {
|
||||
return (
|
||||
<aside className="hidden w-64 flex-shrink-0 lg:block">
|
||||
<div className="sticky top-8 space-y-4">
|
||||
<LeftNavContent />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarCard() {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
|
||||
<span>Календарь</span>
|
||||
<span className="text-xs font-normal text-gray-500">Февраль 2026</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
|
||||
{weekdays.map((day) => (
|
||||
<div key={day} className="py-1 font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
|
||||
{calendarWeeks.map((week, wi) =>
|
||||
week.map((day, di) => {
|
||||
const isToday = day === "18";
|
||||
return (
|
||||
<div
|
||||
key={`${wi}-${di}`}
|
||||
className={`flex h-9 items-center justify-center rounded-full ${
|
||||
day
|
||||
? isToday
|
||||
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
|
||||
: "hover:bg-sky-50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Calendar() {
|
||||
return (
|
||||
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
|
||||
<div className="sticky top-8 space-y-4">
|
||||
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
|
||||
<span>Календарь</span>
|
||||
<span className="text-xs font-normal text-gray-500">Февраль 2026</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
|
||||
{weekdays.map((day) => (
|
||||
<div key={day} className="py-1 font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
|
||||
{calendarWeeks.map((week, wi) =>
|
||||
week.map((day, di) => {
|
||||
const isToday = day === "18";
|
||||
return (
|
||||
<div
|
||||
key={`${wi}-${di}`}
|
||||
className={`flex h-9 items-center justify-center rounded-full ${
|
||||
day
|
||||
? isToday
|
||||
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
|
||||
: "hover:bg-sky-50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CalendarCard />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@ -160,14 +367,98 @@ export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }:
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
|
||||
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobileLeftNavOpen(false);
|
||||
setIsMobileCalendarOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileLeftNavOpen || isMobileCalendarOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.style.overflow = "";
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isMobileLeftNavOpen, isMobileCalendarOpen]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
|
||||
<Header />
|
||||
<div className="mx-auto flex min-h-screen max-w-6xl gap-6 px-4 py-8 sm:px-8">
|
||||
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
|
||||
<div className="mx-auto flex min-h-screen max-w-6xl gap-6 px-4 py-6 sm:px-8 lg:py-8">
|
||||
<LeftNav />
|
||||
<main className="flex-1 space-y-6">{children}</main>
|
||||
<Calendar />
|
||||
</div>
|
||||
|
||||
<div className={`fixed inset-0 z-50 lg:hidden ${isMobileLeftNavOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileLeftNavOpen ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={() => setIsMobileLeftNavOpen(false)}
|
||||
aria-label="Закрыть левое меню"
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
|
||||
isMobileLeftNavOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-gray-900">Меню</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobileLeftNavOpen(false)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
|
||||
aria-label="Закрыть меню"
|
||||
>
|
||||
<X size={20} className="text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`fixed inset-0 z-50 lg:hidden ${isMobileCalendarOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileCalendarOpen ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={() => setIsMobileCalendarOpen(false)}
|
||||
aria-label="Закрыть календарь"
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-y-0 right-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
|
||||
isMobileCalendarOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-gray-900">Календарь</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobileCalendarOpen(false)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
|
||||
aria-label="Закрыть календарь"
|
||||
>
|
||||
<X size={20} className="text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
<CalendarCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
110
src/lib/auth.ts
Normal file
110
src/lib/auth.ts
Normal file
@ -0,0 +1,110 @@
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
login: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const USERS_KEY = "corp.front.users";
|
||||
const SESSION_KEY = "corp.front.session";
|
||||
|
||||
function isBrowser() {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function loadUsers(): AuthUser[] {
|
||||
if (!isBrowser()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(USERS_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AuthUser[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveUsers(users: AuthUser[]) {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(USERS_KEY, JSON.stringify(users));
|
||||
}
|
||||
|
||||
export function seedDemoUser() {
|
||||
const users = loadUsers();
|
||||
const exists = users.some((u) => u.login.toLowerCase() === "demo@corp.local");
|
||||
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
users.push({
|
||||
id: "demo-user",
|
||||
firstName: "Демо",
|
||||
lastName: "Пользователь",
|
||||
login: "demo@corp.local",
|
||||
password: "123456",
|
||||
});
|
||||
|
||||
saveUsers(users);
|
||||
}
|
||||
|
||||
export function registerUser(payload: Omit<AuthUser, "id">): { ok: true } | { ok: false; message: string } {
|
||||
const users = loadUsers();
|
||||
const normalizedLogin = payload.login.trim().toLowerCase();
|
||||
|
||||
const alreadyExists = users.some((u) => u.login.toLowerCase() === normalizedLogin);
|
||||
if (alreadyExists) {
|
||||
return { ok: false, message: "Пользователь с таким логином уже существует" };
|
||||
}
|
||||
|
||||
users.push({
|
||||
id: crypto.randomUUID(),
|
||||
...payload,
|
||||
login: normalizedLogin,
|
||||
});
|
||||
|
||||
saveUsers(users);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function loginUser(login: string, password: string): { ok: true } | { ok: false; message: string } {
|
||||
const users = loadUsers();
|
||||
const normalizedLogin = login.trim().toLowerCase();
|
||||
|
||||
const user = users.find((u) => u.login.toLowerCase() === normalizedLogin && u.password === password);
|
||||
if (!user) {
|
||||
return { ok: false, message: "Неверный логин или пароль" };
|
||||
}
|
||||
|
||||
if (isBrowser()) {
|
||||
window.localStorage.setItem(SESSION_KEY, user.id);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function logoutUser() {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
export function isAuthenticated() {
|
||||
if (!isBrowser()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(window.localStorage.getItem(SESSION_KEY));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user