This commit is contained in:
rngsurrounded
2025-03-02 02:30:11 +09:00
73 changed files with 19910 additions and 231 deletions
@@ -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>
);
};