mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-23 17:57:10 +00:00
feat: authorization
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Competition } from "@/shared/types";
|
||||
import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks";
|
||||
|
||||
@@ -14,7 +14,7 @@ const CompetitionPage = () => {
|
||||
);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (competition?.id) {
|
||||
if (competition?.id) {
|
||||
if (mockTasks && mockTasks.length > 0) {
|
||||
const firstTaskId = mockTasks[0].id;
|
||||
navigate(`/competition/${competition.id}/tasks/${firstTaskId}`);
|
||||
@@ -43,19 +43,19 @@ const CompetitionPage = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<div className="flex flex-col-reverse gap-8 md:flex-row">
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<h1 className="text-[34px] leading-11 font-semibold text-balance">
|
||||
{competition.name}
|
||||
</h1>
|
||||
<div className="text-xl leading-10 font-normal prose prose-lg max-w-none">
|
||||
<ReactMarkdown>
|
||||
{competition.description || ''}
|
||||
</ReactMarkdown>
|
||||
<div className="prose prose-lg max-w-none text-xl leading-10 font-normal">
|
||||
<ReactMarkdown>{competition.description || ""}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-96 *:w-full">
|
||||
<Button onClick={handleContinue}>Продолжить</Button>
|
||||
<div className="w-full *:w-full md:w-96">
|
||||
<Button size={"lg"} onClick={handleContinue}>
|
||||
Продолжить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,4 +63,4 @@ const CompetitionPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionPage;
|
||||
export default CompetitionPage;
|
||||
|
||||
@@ -11,13 +11,9 @@ export function CompetitionCard({
|
||||
competition,
|
||||
className,
|
||||
}: CompetitionCardProps) {
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"aspect-square h-full max-h-80 w-auto overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
className={cn("aspect-square h-full w-auto overflow-hidden", className)}
|
||||
>
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<img
|
||||
@@ -40,7 +36,9 @@ export function CompetitionCard({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">{competition.name}</h3>
|
||||
<h3 className="line-clamp-2 text-xl font-semibold">
|
||||
{competition.name}
|
||||
</h3>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -25,7 +25,7 @@ const CompetitionsPage = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-6 sm:gap-8">
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Мои события</SectionTitle>
|
||||
@@ -50,11 +50,15 @@ const CompetitionsPage = () => {
|
||||
};
|
||||
|
||||
const Section = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="flex flex-col gap-8">{children}</div>;
|
||||
return <div className="flex flex-col gap-6 sm:gap-8">{children}</div>;
|
||||
};
|
||||
|
||||
const SectionHeader = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="flex h-[58px] items-center gap-2">{children}</div>;
|
||||
return (
|
||||
<div className="flex min-h-[58px] flex-col items-center justify-center gap-4 sm:flex-row sm:gap-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ interface CompetitionGridProps {
|
||||
|
||||
export function CompetitionGrid({ competitions }: CompetitionGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-9">
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-3 md:gap-7 lg:gap-9">
|
||||
{competitions.map((competition) => (
|
||||
<Link key={competition.id} to={`/competition/${competition.id}`}>
|
||||
<CompetitionCard competition={competition} />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = ({ label, error, id, ...props }: InputProps) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-stretch gap-2">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-base font-semibold">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
className="bg-card h-12 rounded-xl border px-4 text-base"
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className="text-red-500">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DataRush } from "@/components/ui/icons/datarush";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LoginTab } from "./modules/LoginTab";
|
||||
import { SignupTab } from "./modules/SignupTab";
|
||||
import React from "react";
|
||||
import { getToken } from "@/shared/token";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
navigate("/");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center gap-10 px-4 py-10 sm:gap-18 sm:py-18">
|
||||
<DataRush size={52} />
|
||||
<div className="flex w-full max-w-96 flex-col items-center gap-7">
|
||||
<h1 className="text-center text-4xl font-semibold">
|
||||
Добро пожаловать!
|
||||
</h1>
|
||||
<Tabs
|
||||
defaultValue="login"
|
||||
className="flex w-full flex-col items-center gap-7"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="login">Вход</TabsTrigger>
|
||||
<TabsTrigger value="signup">Регистрация</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="login" asChild>
|
||||
<LoginTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="signup" asChild>
|
||||
<SignupTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "../components/input";
|
||||
import { login } from "@/shared/api/auth";
|
||||
import { saveToken } from "@/shared/token";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ApiError } from "@/shared/api";
|
||||
|
||||
export const LoginTab = () => {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loginAction = async (
|
||||
formData: FormData,
|
||||
e: React.FormEvent<HTMLFormElement>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const email = formData.get("email");
|
||||
const password = formData.get("password");
|
||||
|
||||
if (!email || !password) {
|
||||
setError("Неверное имя пользователя или пароль");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await login({
|
||||
email: email.toString(),
|
||||
password: password.toString(),
|
||||
});
|
||||
saveToken(token.token);
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && (e.status === 400 || e.status === 401)) {
|
||||
setError("Неверное имя пользователя или пароль");
|
||||
} else {
|
||||
setError("Произошла непредвиденная ошибка");
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col items-stretch gap-8"
|
||||
onSubmit={(e) => loginAction(new FormData(e.currentTarget), e)}
|
||||
>
|
||||
<div className="flex flex-col items-stretch gap-5">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
label="Почта"
|
||||
placeholder="vdeniske@megazord.pobeda"
|
||||
/>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
label="Пароль"
|
||||
placeholder="Введите пароль"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
{error && <span className="text-red-500">{error}</span>}
|
||||
<Button type="submit">{loading ? <Spinner size={16} /> : "Войти"}</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "../components/input";
|
||||
import { z } from "zod";
|
||||
import React from "react";
|
||||
import { signup } from "@/shared/api/auth";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { saveToken } from "@/shared/token";
|
||||
import { useNavigate } from "react-router";
|
||||
import { ApiError } from "@/shared/api";
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email({ message: "Некорректная почта" }).trim(),
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: "Имя пользователя должно быть не меньше 1 знака" })
|
||||
.max(50, { message: "Имя пользователя должно быть не больше 50 знаков" })
|
||||
.trim(),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: "Пароль должен быть не меньше 8 знаков" })
|
||||
.regex(/[a-zA-Z]/, {
|
||||
message: "Пароль должен содержать хотя бы одну букву",
|
||||
})
|
||||
.regex(/[0-9]/, { message: "Пароль должен содержать хотя бы одну цифру" })
|
||||
.regex(/[^a-zA-Z0-9]/, {
|
||||
message: "Пароль должен содержать хотя бы один специальный символ",
|
||||
})
|
||||
.trim(),
|
||||
});
|
||||
|
||||
interface SignupFormErrors {
|
||||
username?: string[];
|
||||
email?: string[];
|
||||
password?: string[];
|
||||
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const SignupTab = () => {
|
||||
const navigate = useNavigate();
|
||||
const [errors, setErrors] = React.useState<SignupFormErrors | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const signupAction = async (
|
||||
formData: FormData,
|
||||
e: React.FormEvent<HTMLFormElement>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const validatedFields = signupSchema.safeParse({
|
||||
email: formData.get("email"),
|
||||
username: formData.get("username"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
setErrors(validatedFields.error.flatten().fieldErrors);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await signup({
|
||||
...validatedFields.data,
|
||||
});
|
||||
saveToken(token.token);
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.status === 400) {
|
||||
setErrors({ message: "Неверные данные" });
|
||||
} else if (e.status === 409) {
|
||||
setErrors({
|
||||
message:
|
||||
"Пользователь с такой почтой или именем пользователя уже существует",
|
||||
});
|
||||
} else {
|
||||
setErrors({ message: "Произошла непредвиденная ошибка" });
|
||||
}
|
||||
} else {
|
||||
setErrors({ message: "Произошла непредвиденная ошибка" });
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col items-stretch gap-8"
|
||||
onSubmit={(e) => signupAction(new FormData(e.currentTarget), e)}
|
||||
>
|
||||
<div className="flex flex-col items-stretch gap-5">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
label="Почта"
|
||||
placeholder="vdeniske@megazord.pobeda"
|
||||
error={errors?.email?.at(0)}
|
||||
onChange={() => setErrors(null)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
label="Имя пользователя"
|
||||
placeholder="Введите имя пользователя"
|
||||
error={errors?.username?.at(0)}
|
||||
onChange={() => setErrors(null)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
label="Пароль"
|
||||
placeholder="Введите пароль"
|
||||
type="password"
|
||||
error={errors?.password?.at(0)}
|
||||
onChange={() => setErrors(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors?.message && (
|
||||
<span className="text-red-500">{errors.message}</span>
|
||||
)}
|
||||
|
||||
<Button type="submit" onClick={() => setErrors(null)}>
|
||||
{loading ? <Spinner size={16} /> : "Зарегистрироваться"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user