This commit is contained in:
rngsurrounded
2025-03-02 02:30:11 +09:00
73 changed files with 19910 additions and 231 deletions
+17 -8
View File
@@ -1,21 +1,30 @@
import { Routes, Route } from "react-router";
import "./styles/globals.css";
import Competitions from "./pages/Competitions";
import CompetitionPreview from './pages/CompetitionPreview'
import CompetitionSession from "./pages/CompetitionSession";
import { NavbarLayout } from "./widgets/navbar-layout";
import Competitions from "./pages/Competitions";
import CompetitionPreview from "./pages/CompetitionPreview";
import CompetitionSession from "./pages/CompetitionSession";
import LoginPage from "./pages/Login";
import { AuthLayout } from "./widgets/auth-layout";
const App = () => {
return (
<Routes>
<Route element={<NavbarLayout />}>
<Route path="/" element={<Competitions />} />
<Route path="/competition/:id" element={<CompetitionPreview />} />
</Route>
<Route
<Route path="/login" element={<LoginPage />} />
<Route element={<AuthLayout />}>
<Route element={<NavbarLayout />}>
<Route path="/" element={<Competitions />} />
<Route path="/competition/:id" element={<CompetitionPreview />} />
</Route>
<Route
path="/competition/:id/tasks/:taskId"
element={<CompetitionSession />}
/>
</Route>
</Routes>
);
};
@@ -1,71 +1,77 @@
import React, { useState } from 'react';
import React, { useState } from "react";
import { DataRush } from "@/components/ui/icons/datarush";
import { ChevronDown, User, Settings, BarChart2, LogOut } from "lucide-react";
import { Link } from "react-router";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose,
} from "@/components/ui/sheet";
import { useUserStore } from "@/shared/stores/user";
const Header = () => {
const user = useUserStore((state) => state.user);
const [isProfileOpen, setIsProfileOpen] = useState(false);
return (
<header className="bg-card sticky top-0 z-30 flex h-[72px] w-full items-center justify-center">
<header className="bg-card sticky top-0 z-30 flex h-[72px] w-full items-center justify-center px-4 sm:px-6">
<div className="flex w-full max-w-5xl items-center justify-between">
<Link to="/">
<DataRush />
</Link>
<div
className="flex items-center gap-1 cursor-pointer hover:opacity-80 transition-opacity px-2 py-1 rounded-md"
<div
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 transition-opacity hover:opacity-80"
onClick={() => setIsProfileOpen(true)}
>
<span className="text-lg font-semibold font-hse-sans">itqdev</span>
<span className="font-hse-sans text-lg font-semibold">
{user?.username}
</span>
<ChevronDown size={20} />
</div>
</div>
<Sheet open={isProfileOpen} onOpenChange={setIsProfileOpen}>
<SheetContent className="w-[300px] sm:w-[350px] p-0">
<SheetHeader className="border-b py-4 px-5">
<SheetTitle className="font-hse-sans text-lg font-medium">Профиль</SheetTitle>
<SheetContent className="w-[300px] p-0 sm:w-[350px]">
<SheetHeader className="border-b px-5 py-4">
<SheetTitle className="font-hse-sans text-lg font-medium">
Профиль
</SheetTitle>
</SheetHeader>
<div className="py-4 px-2">
<ProfileOption
icon={<User size={18} />}
label="Ваш профиль"
<div className="px-2 py-4">
<ProfileOption
icon={<User size={18} />}
label="Ваш профиль"
onClick={() => {
setIsProfileOpen(false);
}}
}}
/>
<ProfileOption
icon={<Settings size={18} />}
label="Настройки"
<ProfileOption
icon={<Settings size={18} />}
label="Настройки"
onClick={() => {
setIsProfileOpen(false);
}}
}}
/>
<ProfileOption
icon={<BarChart2 size={18} />}
label="Статистика"
<ProfileOption
icon={<BarChart2 size={18} />}
label="Статистика"
onClick={() => {
setIsProfileOpen(false);
}}
}}
/>
<div className="border-t mt-2 pt-2">
<ProfileOption
icon={<LogOut size={18} />}
label="Выйти"
<div className="mt-2 border-t pt-2">
<ProfileOption
icon={<LogOut size={18} />}
label="Выйти"
onClick={() => {
setIsProfileOpen(false);
}}
}}
/>
</div>
</div>
@@ -82,11 +88,16 @@ interface ProfileOptionProps {
className?: string;
}
const ProfileOption: React.FC<ProfileOptionProps> = ({ icon, label, onClick, className }) => {
const ProfileOption: React.FC<ProfileOptionProps> = ({
icon,
label,
onClick,
className,
}) => {
return (
<SheetClose asChild>
<button
className={`flex items-center gap-3 w-full px-3 py-2.5 rounded-md text-left transition-colors hover:bg-gray-100 ${className || ''}`}
<button
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-gray-100 ${className || ""}`}
onClick={onClick}
>
<span className="text-gray-600">{icon}</span>
@@ -96,4 +107,4 @@ const ProfileOption: React.FC<ProfileOptionProps> = ({ icon, label, onClick, cla
);
};
export { Header };
export { Header };
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
@@ -20,9 +20,9 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-12 px-5 py-3 has-[>svg]:px-3 text-lg font-semibold",
default: "h-11 px-4 text-base font-semibold rounded-xl",
lg: "h-12 px-5 py-3 has-[>svg]:px-3 text-lg font-semibold",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
@@ -0,0 +1,5 @@
import { Loader } from "lucide-react";
export const Spinner = (props: React.ComponentProps<typeof Loader>) => (
<Loader className="animate-spin" {...props} />
);
@@ -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>
);
};
+23
View File
@@ -0,0 +1,23 @@
import { authFetch } from ".";
interface AuthResponse {
token: string;
}
export const signup = async (body: {
email: string;
username: string;
password: string;
}) => {
return await authFetch<AuthResponse>("/sign-up", {
method: "POST",
body,
});
};
export const login = async (body: { email: string; password: string }) => {
return await authFetch<AuthResponse>("/sign-in", {
method: "POST",
body,
});
};
+38
View File
@@ -0,0 +1,38 @@
import { ofetch } from "ofetch";
import { getToken, removeToken } from "../token";
const BASE_URL = import.meta.env.VITE_API_ENDPOINT;
export class ApiError extends Error {
response: Response;
status: number;
constructor(response: Response) {
super(response.statusText);
this.response = response;
this.status = response.status;
}
}
export const authFetch = ofetch.create({
baseURL: BASE_URL,
async onResponseError({ response }) {
throw new ApiError(response);
},
});
export const apiFetch = ofetch.create({
baseURL: BASE_URL,
headers: {
Authorization: "Bearer " + getToken(),
},
async onResponseError({ response }) {
if (response.status === 401) {
removeToken();
window.location.href = "/login";
return;
}
throw new ApiError(response);
},
});
+6
View File
@@ -0,0 +1,6 @@
import { apiFetch } from ".";
import { User } from "../types/user";
export const getCurrentUser = async () => {
return await apiFetch<User>("/me");
};
@@ -0,0 +1,23 @@
import { create } from "zustand";
import { User } from "../types/user";
import { getCurrentUser } from "../api/user";
interface UserState {
user: User | null;
loading: boolean;
fetchUser: () => Promise<void>;
}
const useUserStore = create<UserState>((set) => ({
user: null,
loading: true,
fetchUser: async () => {
set({ loading: true });
const user = await getCurrentUser();
set({ user, loading: false });
},
}));
export { useUserStore };
+17
View File
@@ -0,0 +1,17 @@
import Cookie from "js-cookie";
export const getToken = () => {
return Cookie.get("token");
};
export const saveToken = (token: string) => {
Cookie.set("token", token, {
secure: true,
sameSite: "Strict",
expires: 30,
});
};
export const removeToken = () => {
Cookie.remove("token");
};
@@ -0,0 +1,5 @@
export interface User {
id: string;
email: string;
username: string;
}
@@ -0,0 +1,17 @@
import { useUserStore } from "@/shared/stores/user";
import React from "react";
import { Outlet } from "react-router";
export const AuthLayout = () => {
const fetchUser = useUserStore((state) => state.fetchUser);
React.useEffect(() => {
async function fetchData() {
await fetchUser();
}
fetchData();
}, []);
return <Outlet />;
};
@@ -5,8 +5,10 @@ const NavbarLayout = () => {
return (
<>
<Header />
<div className="m-auto mt-6 w-full max-w-5xl">
<Outlet />
<div className="px-4 sm:px-6">
<div className="m-auto mt-6 w-full max-w-5xl">
<Outlet />
</div>
</div>
</>
);