initial layout

This commit is contained in:
Daniil
2026-01-19 23:19:58 +03:00
parent 749fda017c
commit 4688f65c5a
81 changed files with 4343 additions and 16 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./ui/LoginPage"
+3
View File
@@ -0,0 +1,3 @@
export interface ILoginPageProps {
message?: string
}
@@ -0,0 +1,41 @@
.root {
}
.form {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 520px;
max-height: 720px;
display: flex;
flex-direction: column;
gap: 16px;
}
.title {
@include typography.font-header-l;
width: 100%;
text-align: center;
}
.fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions {
display: flex;
justify-content: center;
}
.link {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
+84
View File
@@ -0,0 +1,84 @@
"use client"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import { useForm } from "react-hook-form"
import Link from "next/link"
import api from "@shared/api"
import { useCookie } from "@shared/hooks/useCookie"
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
} from "@shared/lib/constants"
import { Button, Form, TextField } from "@shared/ui"
import { ILoginPageProps } from "../model/LoginPage.d"
import styles from "./LoginPage.module.scss"
interface ILoginFormData {
login: string
password: string
}
export const LoginPage: FunctionComponent<
ILoginPageProps
> = (): JSX.Element => {
const [, setAccessTokenCookie] = useCookie(ACCESS_TOKEN_COOKIE)
const [, setRefreshTokenCookie] = useCookie(REFRESH_TOKEN_COOKIE)
const { mutate, isPending } = api.useMutation("post", "/auth/login", {
onSuccess: ({ access, refresh, user }) => {
setAccessTokenCookie(access)
setRefreshTokenCookie(refresh)
console.log("Login successful:", user)
},
onError: (error) => {
console.error("Login failed:", error)
},
})
const { register, handleSubmit } = useForm<ILoginFormData>()
const onSubmit = (data: ILoginFormData): void => {
mutate({
body: {
username: data.login,
password: data.password,
},
})
}
return (
<div className={styles.root} data-testid="LoginPage">
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<h1 className={styles.title}>Вход</h1>
<div className={styles.fields}>
<TextField
id="login"
label="Логин"
placeholder="Ваш логин"
{...register("login")}
/>
<TextField
id="password"
label="Пароль"
placeholder="Ваш пароль"
type="password"
{...register("password")}
/>
</div>
<Button type="submit" variant="primary" disabled={isPending}>
Войти
</Button>
<div className={styles.actions}>
<Link className={styles.link} href="/register">
Нет аккаунта? Зарегистрироваться
</Link>
</div>
</Form>
</div>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./ui/RegisterPage"
+3
View File
@@ -0,0 +1,3 @@
export interface IRegisterPageProps {
message?: string
}
@@ -0,0 +1,29 @@
.root {
opacity: 1;
}
.form {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 520px;
max-height: 820px;
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.title {
@include typography.font-header-l;
width: 100%;
text-align: center;
}
.fields {
display: flex;
flex-direction: column;
gap: 8px;
}
+114
View File
@@ -0,0 +1,114 @@
"use client"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import { useForm } from "react-hook-form"
import { useHookFormMask } from "use-mask-input"
import api from "@shared/api"
import { Button, Form, TextField } from "@shared/ui"
import { IRegisterPageProps } from "../model/RegisterPage.d"
import styles from "./RegisterPage.module.scss"
interface IRegisterFormData {
username: string
email: string
password: string
first_name: string
last_name: string
phone_number?: string
avatar?: string
}
export const RegisterPage: FunctionComponent<
IRegisterPageProps
> = (): JSX.Element => {
const { mutate, isPending } = api.useMutation("post", "/auth/register", {
onSuccess: (data) => {
console.log("Register successful:", data)
},
onError: (error) => {
console.error("Register failed:", error)
},
})
const { register, handleSubmit } = useForm<IRegisterFormData>()
const registerWithMask = useHookFormMask(register)
const onSubmit = (data: IRegisterFormData): void => {
const phone = data.phone_number?.trim()
const avatar = data.avatar?.trim()
mutate({
body: {
username: data.username,
email: data.email,
password: data.password,
first_name: data.first_name,
last_name: data.last_name,
phone_number: phone || undefined,
avatar: avatar || undefined,
},
})
}
return (
<div className={styles.root} data-testid="RegisterPage">
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<h1 className={styles.title}>Регистрация</h1>
<div className={styles.fields}>
<TextField
id="username"
label="Имя пользователя"
placeholder="Ваш никнейм"
{...register("username")}
/>
<TextField
id="email"
label="Email"
placeholder="you@example.com"
type="email"
{...register("email")}
/>
<TextField
id="password"
label="Пароль"
placeholder="Придумайте пароль"
type="password"
{...register("password")}
/>
<TextField
id="first_name"
label="Имя"
placeholder="Ваше имя"
{...register("first_name")}
/>
<TextField
id="last_name"
label="Фамилия"
placeholder="Ваша фамилия"
{...register("last_name")}
/>
<TextField
id="phone_number"
label="Номер телефона"
placeholder="+7 (___) ___-__-__"
{...registerWithMask("phone_number", "+7 (999) 999-99-99")}
/>
<TextField
id="avatar"
label="Аватар"
placeholder="Ссылка на изображение"
{...register("avatar")}
/>
</div>
<Button type="submit" variant="primary" disabled={isPending}>
Создать аккаунт
</Button>
</Form>
</div>
)
}
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
import createClient from "openapi-react-query"
import createFetchClient, { Middleware } from "openapi-fetch"
import { ACCESS_TOKEN_REGEXP, API_URL } from "@shared/lib/constants"
import { paths } from "./__generated__/openapi.types"
const isServer = typeof window === "undefined"
const getAccessTokenFromCookieHeader = (
cookieHeader: string | null,
): string | undefined => {
if (!cookieHeader) return
const token = cookieHeader.replace(ACCESS_TOKEN_REGEXP, "$1")
return token.length ? token : undefined
}
export const fetchClient = createFetchClient<paths>({
baseUrl: API_URL,
// credentials: "include",
headers: {
"Content-Type": "application/json",
},
})
const middleware: Middleware = {
async onRequest({ request }) {
if (request.headers.has("Authorization")) return
let token: string | undefined
if (isServer) {
// In middleware/edge runtime there is no `next/headers` request scope.
token = getAccessTokenFromCookieHeader(request.headers.get("cookie"))
if (!token) {
try {
const { cookies } = await import("next/headers")
token = (await cookies()).get("access_token")?.value
} catch {
// Not in a request scope (e.g. middleware/edge or build-time).
}
}
} else {
token = document.cookie.replace(ACCESS_TOKEN_REGEXP, "$1")
}
if (token?.length) request.headers.set("Authorization", `Bearer ${token}`)
},
async onError({ error }) {
return new Error("Oops, fetch failed", { cause: error })
},
}
fetchClient.use(middleware)
export const api = createClient(fetchClient)
export default api
+29
View File
@@ -0,0 +1,29 @@
"use server"
import { fetchClient } from "."
export const pingServer = async (): Promise<boolean> => {
try {
await fetchClient.GET("/api/ping/")
return true
} catch (error) {
console.error("Ping server error:", error)
return false
}
}
export const verifyToken = async (token: string): Promise<boolean> => {
console.log("Verifying token:", token)
try {
const resp = await fetchClient.GET("/api/users/me/", {
headers: {
Authorization: `Bearer ${token}`,
},
})
console.log("Verify token response:", resp)
return true
} catch (error) {
console.error("Verify token error:", error)
return false
}
}
@@ -0,0 +1,18 @@
"use client"
import { QueryClientProvider as QueryClientProviderTanstack } from "@tanstack/react-query"
import { JSX, Suspense } from "react"
import { queryClient } from "@shared/lib/query_client"
export const QueryClientProvider = ({
children,
}: {
children: React.ReactNode
}): JSX.Element => {
return (
<QueryClientProviderTanstack client={queryClient}>
{children}
</QueryClientProviderTanstack>
)
}
+13
View File
@@ -0,0 +1,13 @@
import Cookies from "js-cookie"
export const useCookie = (key: string, defaultValue: string | null = null) => {
const getCookie = Cookies.get(key) || defaultValue
const setCookie = (value: string) => Cookies.set(key, value)
const removeCookie = () => Cookies.remove(key)
return [getCookie, setCookie, removeCookie] as [
string | null,
(value: string) => void,
() => void,
]
}
+37
View File
@@ -0,0 +1,37 @@
export const ACCESS_TOKEN_COOKIE = "access_token"
export const REFRESH_TOKEN_COOKIE = "refresh_token"
export const ACCESS_TOKEN_REGEXP = new RegExp(
/(?:(?:^|.*;\s*)access_token\s*=\s*([^;]*).*$)|^.*$/,
)
export const API_URL = process.env.NEXT_PUBLIC_API_URL
// Paths that can be accessed without authentication and without redirecting to login
export const ESSENTIAL_PATHS = [
".*/login.*",
".*/register.*",
".*/reset-password.*",
".*/recover.*",
]
// Paths that are excluded from authentication checks
export const EXCLUDED_PATHS = [
"^/public/.*",
"^/_next.*",
"^/static.*",
"^/fonts.*",
".*/api.*",
".*/logout.*",
".*/confirm-email.*",
".*/recover.*",
".*/manifest.json.*",
".*/android.*",
".*/apple.*",
".*/favicon.*",
".*/workbox.*",
".*/sw.js.*",
]
export const ENTRY_PATHS_REGEXP = new RegExp(ESSENTIAL_PATHS.join("|"))
export const EXCLUDE_PATHS_REGEXP = new RegExp(EXCLUDED_PATHS.join("|"))
+3
View File
@@ -0,0 +1,3 @@
import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient()
View File
+4
View File
@@ -14,6 +14,10 @@
body {
background-color: #f8f8f8;
@media (prefers-color-scheme: dark) {
background-color: #121212;
}
}
:root {
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Alert"
+3
View File
@@ -0,0 +1,3 @@
import type { AlertProps } from "react-bootstrap/Alert"
export interface IAlertProps extends AlertProps {}
+13
View File
@@ -0,0 +1,13 @@
"use client"
import type { IAlertProps } from "../model/Alert.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapAlert from "react-bootstrap/Alert"
export const Alert = forwardRef<HTMLDivElement, IAlertProps>(
(props, ref): JSX.Element => <BootstrapAlert ref={ref} {...props} />,
)
Alert.displayName = "Alert"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Badge"
+3
View File
@@ -0,0 +1,3 @@
import type { BadgeProps } from "react-bootstrap/Badge"
export interface IBadgeProps extends BadgeProps {}
+13
View File
@@ -0,0 +1,13 @@
"use client"
import type { IBadgeProps } from "../model/Badge.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapBadge from "react-bootstrap/Badge"
export const Badge = forwardRef<HTMLSpanElement, IBadgeProps>(
(props, ref): JSX.Element => <BootstrapBadge ref={ref} {...props} />,
)
Badge.displayName = "Badge"
+1 -3
View File
@@ -1,3 +1 @@
export interface IButtonProps {
message?: string
}
export * from "./model/Button.d"
+1 -7
View File
@@ -1,7 +1 @@
import BootstrapButton, { ButtonProps } from "react-bootstrap/Button"
export const Button = (props: ButtonProps) => (
<BootstrapButton variant="primary" {...props}>
{props.children}
</BootstrapButton>
)
export * from "./ui/Button"
+1 -1
View File
@@ -1 +1 @@
export * from "./Button"
export * from "./ui/Button"
+3
View File
@@ -0,0 +1,3 @@
import type { ButtonProps } from "react-bootstrap/Button"
export interface IButtonProps extends ButtonProps {}
+13
View File
@@ -0,0 +1,13 @@
"use client"
import type { IButtonProps } from "../model/Button.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapButton from "react-bootstrap/Button"
export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
(props, ref): JSX.Element => <BootstrapButton ref={ref} {...props} />,
)
Button.displayName = "Button"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Card"
+3
View File
@@ -0,0 +1,3 @@
import type { CardProps } from "react-bootstrap/Card"
export interface ICardProps extends CardProps {}
+13
View File
@@ -0,0 +1,13 @@
"use client"
import type { ICardProps } from "../model/Card.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapCard from "react-bootstrap/Card"
export const Card = forwardRef<HTMLDivElement, ICardProps>(
(props, ref): JSX.Element => <BootstrapCard ref={ref} {...props} />,
)
Card.displayName = "Card"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Checkbox"
+3
View File
@@ -0,0 +1,3 @@
import type { FormCheckProps } from "react-bootstrap/FormCheck"
export interface ICheckboxProps extends Omit<FormCheckProps, "type"> {}
+15
View File
@@ -0,0 +1,15 @@
"use client"
import type { ICheckboxProps } from "../model/Checkbox.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapFormCheck from "react-bootstrap/FormCheck"
export const Checkbox = forwardRef<HTMLInputElement, ICheckboxProps>(
(props, ref): JSX.Element => (
<BootstrapFormCheck ref={ref} type="checkbox" {...props} />
),
)
Checkbox.displayName = "Checkbox"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Form"
+3
View File
@@ -0,0 +1,3 @@
import type { FormProps } from "react-bootstrap/Form"
export interface IFormProps extends FormProps {}
+13
View File
@@ -0,0 +1,13 @@
"use client"
import type { IFormProps } from "../model/Form.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapForm from "react-bootstrap/Form"
export const Form = forwardRef<HTMLFormElement, IFormProps>(
(props, ref): JSX.Element => <BootstrapForm ref={ref} {...props} />,
)
Form.displayName = "Form"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Modal"
+3
View File
@@ -0,0 +1,3 @@
import type { ModalProps } from "react-bootstrap/Modal"
export interface IModalProps extends ModalProps {}
+13
View File
@@ -0,0 +1,13 @@
"use client"
import type { IModalProps } from "../model/Modal.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapModal from "react-bootstrap/Modal"
export const Modal = forwardRef<HTMLDivElement, IModalProps>(
(props, ref): JSX.Element => <BootstrapModal ref={ref} {...props} />,
)
Modal.displayName = "Modal"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Pagination"
+3
View File
@@ -0,0 +1,3 @@
import type { PaginationProps } from "react-bootstrap/Pagination"
export interface IPaginationProps extends PaginationProps {}
@@ -0,0 +1,13 @@
"use client"
import type { IPaginationProps } from "../model/Pagination.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapPagination from "react-bootstrap/Pagination"
export const Pagination = forwardRef<HTMLUListElement, IPaginationProps>(
(props, ref): JSX.Element => <BootstrapPagination ref={ref} {...props} />,
)
Pagination.displayName = "Pagination"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Radio"
+3
View File
@@ -0,0 +1,3 @@
import type { FormCheckProps } from "react-bootstrap/FormCheck"
export interface IRadioProps extends Omit<FormCheckProps, "type"> {}
+15
View File
@@ -0,0 +1,15 @@
"use client"
import type { IRadioProps } from "../model/Radio.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapFormCheck from "react-bootstrap/FormCheck"
export const Radio = forwardRef<HTMLInputElement, IRadioProps>(
(props, ref): JSX.Element => (
<BootstrapFormCheck ref={ref} type="radio" {...props} />
),
)
Radio.displayName = "Radio"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Select"
+3
View File
@@ -0,0 +1,3 @@
import type { FormSelectProps } from "react-bootstrap/FormSelect"
export interface ISelectProps extends FormSelectProps {}
+13
View File
@@ -0,0 +1,13 @@
"use client"
import type { ISelectProps } from "../model/Select.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapFormSelect from "react-bootstrap/FormSelect"
export const Select = forwardRef<HTMLSelectElement, ISelectProps>(
(props, ref): JSX.Element => <BootstrapFormSelect ref={ref} {...props} />,
)
Select.displayName = "Select"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Table"
+3
View File
@@ -0,0 +1,3 @@
import type { TableProps } from "react-bootstrap/Table"
export interface ITableProps extends TableProps {}
+13
View File
@@ -0,0 +1,13 @@
"use client"
import type { ITableProps } from "../model/Table.d"
import type { JSX } from "react"
import { forwardRef } from "react"
import BootstrapTable from "react-bootstrap/Table"
export const Table = forwardRef<HTMLTableElement, ITableProps>(
(props, ref): JSX.Element => <BootstrapTable ref={ref} {...props} />,
)
Table.displayName = "Table"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Tabs"
+3
View File
@@ -0,0 +1,3 @@
import type { TabsProps } from "react-bootstrap/Tabs"
export interface ITabsProps extends TabsProps {}
+18
View File
@@ -0,0 +1,18 @@
"use client"
import type { ITabsProps } from "../model/Tabs.d"
import type { ForwardRefExoticComponent, JSX, RefAttributes } from "react"
import { forwardRef } from "react"
import BootstrapTabs from "react-bootstrap/Tabs"
const BootstrapTabsWithRef =
BootstrapTabs as unknown as ForwardRefExoticComponent<
ITabsProps & RefAttributes<HTMLDivElement>
>
export const Tabs = forwardRef<HTMLDivElement, ITabsProps>(
(props, ref): JSX.Element => <BootstrapTabsWithRef ref={ref} {...props} />,
)
Tabs.displayName = "Tabs"
+1
View File
@@ -0,0 +1 @@
export * from "./ui/TextField"
+7
View File
@@ -0,0 +1,7 @@
import type { FormControlProps } from "react-bootstrap/FormControl"
export interface ITextFieldProps extends FormControlProps {
id: string
label?: string
undertitle?: string
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import type { ITextFieldProps } from "../model/TextField.d"
import type { JSX } from "react"
import React, { forwardRef } from "react"
import BootstrapForm from "react-bootstrap/Form"
export const TextField = forwardRef<HTMLInputElement, ITextFieldProps>(
({ id, label, undertitle, ...props }, ref): JSX.Element => (
<React.Fragment>
{label && <BootstrapForm.Label htmlFor={id}>{label}</BootstrapForm.Label>}
<BootstrapForm.Control
id={id}
ref={ref}
{...props}
aria-describedby={`${id}-undertitle`}
/>
{undertitle && (
<BootstrapForm.Text id={`${id}-undertitle`} muted>
{undertitle}
</BootstrapForm.Text>
)}
</React.Fragment>
),
)
TextField.displayName = "TextField"
+12
View File
@@ -1 +1,13 @@
export * from "./Alert"
export * from "./Badge"
export * from "./Button"
export * from "./Card"
export * from "./Checkbox"
export * from "./Form"
export * from "./TextField"
export * from "./Modal"
export * from "./Pagination"
export * from "./Radio"
export * from "./Select"
export * from "./Table"
export * from "./Tabs"