add: login status detection in frontend
This commit is contained in:
parent
9dd06fa7bc
commit
fa5ab258da
@ -4,7 +4,7 @@ import { SlidingWindow } from "@core/mq/slidingWindow.ts";
|
|||||||
import { getCaptchaConfigMaxDuration, getCurrentCaptchaDifficulty } from "@/lib/auth/captchaDifficulty.ts";
|
import { getCaptchaConfigMaxDuration, getCurrentCaptchaDifficulty } from "@/lib/auth/captchaDifficulty.ts";
|
||||||
import { sqlCred } from "@core/db/dbNew.ts";
|
import { sqlCred } from "@core/db/dbNew.ts";
|
||||||
import { redis } from "@core/db/redis.ts";
|
import { redis } from "@core/db/redis.ts";
|
||||||
import { verify } from 'hono/jwt';
|
import { verify } from "hono/jwt";
|
||||||
import { JwtTokenInvalid, JwtTokenExpired } from "hono/utils/jwt/types";
|
import { JwtTokenInvalid, JwtTokenExpired } from "hono/utils/jwt/types";
|
||||||
import { getJWTsecret } from "@/lib/auth/getJWTsecret.ts";
|
import { getJWTsecret } from "@/lib/auth/getJWTsecret.ts";
|
||||||
import { lockManager } from "@core/mq/lockManager.ts";
|
import { lockManager } from "@core/mq/lockManager.ts";
|
||||||
@ -23,7 +23,8 @@ export const captchaMiddleware = async (c: Context, next: Next) => {
|
|||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: "'Authorization' header is missing.",
|
message: "'Authorization' header is missing.",
|
||||||
code: "UNAUTHORIZED"
|
code: "UNAUTHORIZED",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 401);
|
return c.json<ErrorResponse>(response, 401);
|
||||||
}
|
}
|
||||||
@ -32,7 +33,8 @@ export const captchaMiddleware = async (c: Context, next: Next) => {
|
|||||||
if (!authIsBearer || authHeader.length < 8) {
|
if (!authIsBearer || authHeader.length < 8) {
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: "'Authorization' header is invalid.",
|
message: "'Authorization' header is invalid.",
|
||||||
code: "INVALID_HEADER"
|
code: "INVALID_HEADER",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 400);
|
return c.json<ErrorResponse>(response, 400);
|
||||||
}
|
}
|
||||||
@ -60,47 +62,48 @@ export const captchaMiddleware = async (c: Context, next: Next) => {
|
|||||||
if (consumed) {
|
if (consumed) {
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: "Token has already been used.",
|
message: "Token has already been used.",
|
||||||
code: "INVALID_CREDENTIALS"
|
code: "INVALID_CREDENTIALS",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 401);
|
return c.json<ErrorResponse>(response, 401);
|
||||||
}
|
}
|
||||||
if (difficulty < requiredDifficulty) {
|
if (difficulty < requiredDifficulty) {
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: "Token too weak.",
|
message: "Token too weak.",
|
||||||
code: "UNAUTHORIZED"
|
code: "UNAUTHORIZED",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 401);
|
return c.json<ErrorResponse>(response, 401);
|
||||||
}
|
}
|
||||||
const EXPIRE_FIVE_MINUTES = 300;
|
const EXPIRE_FIVE_MINUTES = 300;
|
||||||
await lockManager.acquireLock(tokenID, EXPIRE_FIVE_MINUTES);
|
await lockManager.acquireLock(tokenID, EXPIRE_FIVE_MINUTES);
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
if (e instanceof JwtTokenInvalid) {
|
if (e instanceof JwtTokenInvalid) {
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: "Failed to verify the token.",
|
message: "Failed to verify the token.",
|
||||||
code: "INVALID_CREDENTIALS"
|
code: "INVALID_CREDENTIALS",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 400);
|
return c.json<ErrorResponse>(response, 400);
|
||||||
}
|
} else if (e instanceof JwtTokenExpired) {
|
||||||
else if (e instanceof JwtTokenExpired) {
|
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: "Token expired.",
|
message: "Token expired.",
|
||||||
code: "INVALID_CREDENTIALS"
|
code: "INVALID_CREDENTIALS",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 400);
|
return c.json<ErrorResponse>(response, 400);
|
||||||
}
|
} else if (e instanceof ValidationError) {
|
||||||
else if (e instanceof ValidationError) {
|
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
code: "INVALID_QUERY_PARAMS",
|
code: "INVALID_QUERY_PARAMS",
|
||||||
message: "Invalid query parameters",
|
message: "Invalid query parameters",
|
||||||
errors: e.errors
|
errors: e.errors
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 400);
|
return c.json<ErrorResponse>(response, 400);
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: "Unknown error.",
|
message: "Unknown error.",
|
||||||
code: "UNKNOWN_ERROR"
|
code: "UNKNOWN_ERROR",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 500);
|
return c.json<ErrorResponse>(response, 500);
|
||||||
}
|
}
|
||||||
@ -114,4 +117,4 @@ export const captchaMiddleware = async (c: Context, next: Next) => {
|
|||||||
await window.event(`captcha-${identifierWithIP}`);
|
await window.event(`captcha-${identifierWithIP}`);
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
@ -34,7 +34,8 @@ export const verifyChallengeHandler = createHandlers(
|
|||||||
if (!ans) {
|
if (!ans) {
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: "Missing required query parameter: ans",
|
message: "Missing required query parameter: ans",
|
||||||
code: "INVALID_QUERY_PARAMS"
|
code: "INVALID_QUERY_PARAMS",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 400);
|
return c.json<ErrorResponse>(response, 400);
|
||||||
}
|
}
|
||||||
@ -46,26 +47,30 @@ export const verifyChallengeHandler = createHandlers(
|
|||||||
code: "ENTITY_NOT_FOUND",
|
code: "ENTITY_NOT_FOUND",
|
||||||
i18n: {
|
i18n: {
|
||||||
key: "backend.error.captcha_not_found"
|
key: "backend.error.captcha_not_found"
|
||||||
}
|
},
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 401);
|
return c.json<ErrorResponse>(response, 401);
|
||||||
} else if (data.error && res.status === 400) {
|
} else if (data.error && res.status === 400) {
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: data.error,
|
message: data.error,
|
||||||
code: "INVALID_QUERY_PARAMS"
|
code: "INVALID_QUERY_PARAMS",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 400);
|
return c.json<ErrorResponse>(response, 400);
|
||||||
} else if (data.error) {
|
} else if (data.error) {
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: data.error,
|
message: data.error,
|
||||||
code: "UNKNOWN_ERROR"
|
code: "UNKNOWN_ERROR",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 500);
|
return c.json<ErrorResponse>(response, 500);
|
||||||
}
|
}
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
const response: ErrorResponse = {
|
const response: ErrorResponse = {
|
||||||
message: "Incorrect answer",
|
message: "Incorrect answer",
|
||||||
code: "INVALID_CREDENTIALS"
|
code: "INVALID_CREDENTIALS",
|
||||||
|
errors: []
|
||||||
};
|
};
|
||||||
return c.json<ErrorResponse>(response, 401);
|
return c.json<ErrorResponse>(response, 401);
|
||||||
}
|
}
|
||||||
|
@ -1 +1,2 @@
|
|||||||
export * from "./register.ts";
|
export * from "./POST.ts";
|
||||||
|
export * from "./session/[id]/GET.ts";
|
||||||
|
32
packages/backend/routes/user/session/[id]/GET.ts
Normal file
32
packages/backend/routes/user/session/[id]/GET.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { Bindings, BlankEnv } from "hono/types";
|
||||||
|
import { ErrorResponse } from "src/schema";
|
||||||
|
import { createHandlers } from "src/utils.ts";
|
||||||
|
import { sqlCred } from "@core/db/dbNew";
|
||||||
|
import { DatabaseUserType } from "@core/db/schema";
|
||||||
|
|
||||||
|
export const getUserByLoginSessionHandler = createHandlers(
|
||||||
|
async (c: Context<BlankEnv & { Bindings: Bindings }, "/user/session/:id">) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const users = await sqlCred<DatabaseUserType[]>`
|
||||||
|
SELECT u.*
|
||||||
|
FROM users u
|
||||||
|
JOIN login_sessions ls ON u.id = ls.uid
|
||||||
|
WHERE ls.id = ${id};
|
||||||
|
`;
|
||||||
|
if (users.length === 0) {
|
||||||
|
const response: ErrorResponse = {
|
||||||
|
message: "Cannot find user",
|
||||||
|
code: "ENTITY_NOT_FOUND",
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
return c.json<ErrorResponse>(response, 404);
|
||||||
|
}
|
||||||
|
const user = users[0];
|
||||||
|
return c.json({
|
||||||
|
username: user.username,
|
||||||
|
nickname: user.nickname,
|
||||||
|
role: user.role
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -1,23 +1,23 @@
|
|||||||
import { rootHandler } from "routes";
|
import { rootHandler } from "routes";
|
||||||
import { pingHandler } from "routes/ping";
|
import { pingHandler } from "routes/ping";
|
||||||
import { registerHandler } from "routes/user";
|
import { getUserByLoginSessionHandler, registerHandler } from "routes/user";
|
||||||
import { videoInfoHandler, getSnapshotsHanlder } from "routes/video";
|
import { videoInfoHandler, getSnapshotsHanlder } from "routes/video";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { Variables } from "hono/types";
|
import { Variables } from "hono/types";
|
||||||
import { createCaptchaSessionHandler, verifyChallengeHandler } from "routes/captcha";
|
import { createCaptchaSessionHandler, verifyChallengeHandler } from "routes/captcha";
|
||||||
import { getCaptchaDifficultyHandler } from "../routes/captcha/difficulty/GET.ts";
|
import { getCaptchaDifficultyHandler } from "routes/captcha/difficulty/GET.ts";
|
||||||
|
|
||||||
export function configureRoutes(app: Hono<{ Variables: Variables }>) {
|
export function configureRoutes(app: Hono<{ Variables: Variables }>) {
|
||||||
app.get("/", ...rootHandler);
|
app.get("/", ...rootHandler);
|
||||||
app.all("/ping", ...pingHandler);
|
app.all("/ping", ...pingHandler);
|
||||||
|
|
||||||
app.get("/video/:id/snapshots", ...getSnapshotsHanlder);
|
app.get("/video/:id/snapshots", ...getSnapshotsHanlder);
|
||||||
app.post("/user", ...registerHandler);
|
|
||||||
|
|
||||||
app.get("/video/:id/info", ...videoInfoHandler);
|
app.get("/video/:id/info", ...videoInfoHandler);
|
||||||
|
|
||||||
|
app.post("/user", ...registerHandler);
|
||||||
|
app.get("/user/session/:id", ...getUserByLoginSessionHandler);
|
||||||
|
|
||||||
app.post("/captcha/session", ...createCaptchaSessionHandler);
|
app.post("/captcha/session", ...createCaptchaSessionHandler);
|
||||||
app.get("/captcha/:id/result", ...verifyChallengeHandler);
|
app.get("/captcha/:id/result", ...verifyChallengeHandler);
|
||||||
|
app.get("/captcha/difficulty", ...getCaptchaDifficultyHandler);
|
||||||
app.get("/captcha/difficulty", ...getCaptchaDifficultyHandler)
|
|
||||||
}
|
}
|
||||||
|
6
packages/backend/src/schema.d.ts
vendored
6
packages/backend/src/schema.d.ts
vendored
@ -43,6 +43,12 @@ export interface SignUpResponse {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
username: string;
|
||||||
|
nickname: string | null;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type CaptchaVerificationRawResponse = {
|
export type CaptchaVerificationRawResponse = {
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
9
packages/core/db/schema.d.ts
vendored
9
packages/core/db/schema.d.ts
vendored
@ -53,3 +53,12 @@ export interface SnapshotScheduleType {
|
|||||||
finished_at?: string;
|
finished_at?: string;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DatabaseUserType {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string | null;
|
||||||
|
password: string;
|
||||||
|
unq_id: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
import type { Client } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
|
|
||||||
import type { VideoSnapshotType } from "./schema.d.ts";
|
|
||||||
|
|
||||||
export async function getVideoSnapshots(
|
|
||||||
client: Client,
|
|
||||||
aid: number,
|
|
||||||
limit: number,
|
|
||||||
pageOrOffset: number,
|
|
||||||
reverse: boolean,
|
|
||||||
mode: "page" | "offset" = "page",
|
|
||||||
) {
|
|
||||||
const offset = mode === "page" ? (pageOrOffset - 1) * limit : pageOrOffset;
|
|
||||||
const queryDesc: string = `
|
|
||||||
SELECT *
|
|
||||||
FROM video_snapshot
|
|
||||||
WHERE aid = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $2
|
|
||||||
OFFSET $3
|
|
||||||
`;
|
|
||||||
const queryAsc: string = `
|
|
||||||
SELECT *
|
|
||||||
FROM video_snapshot
|
|
||||||
WHERE aid = $1
|
|
||||||
ORDER BY created_at
|
|
||||||
LIMIT $2 OFFSET $3
|
|
||||||
`;
|
|
||||||
const query = reverse ? queryAsc : queryDesc;
|
|
||||||
const queryResult = await client.queryObject<VideoSnapshotType>(query, [aid, limit, offset]);
|
|
||||||
return queryResult.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getVideoSnapshotsByBV(
|
|
||||||
client: Client,
|
|
||||||
bv: string,
|
|
||||||
limit: number,
|
|
||||||
pageOrOffset: number,
|
|
||||||
reverse: boolean,
|
|
||||||
mode: "page" | "offset" = "page",
|
|
||||||
) {
|
|
||||||
const offset = mode === "page" ? (pageOrOffset - 1) * limit : pageOrOffset;
|
|
||||||
const queryAsc = `
|
|
||||||
SELECT vs.*
|
|
||||||
FROM video_snapshot vs
|
|
||||||
JOIN bilibili_metadata bm ON vs.aid = bm.aid
|
|
||||||
WHERE bm.bvid = $1
|
|
||||||
ORDER BY vs.created_at
|
|
||||||
LIMIT $2
|
|
||||||
OFFSET $3
|
|
||||||
`;
|
|
||||||
const queryDesc: string = `
|
|
||||||
SELECT *
|
|
||||||
FROM video_snapshot vs
|
|
||||||
JOIN bilibili_metadata bm ON vs.aid = bm.aid
|
|
||||||
WHERE bm.bvid = $1
|
|
||||||
ORDER BY vs.created_at DESC
|
|
||||||
LIMIT $2 OFFSET $3
|
|
||||||
`;
|
|
||||||
const query = reverse ? queryAsc : queryDesc;
|
|
||||||
const queryResult = await client.queryObject<VideoSnapshotType>(query, [bv, limit, offset]);
|
|
||||||
return queryResult.rows;
|
|
||||||
}
|
|
@ -2,7 +2,7 @@
|
|||||||
font-family: "MiSans VF";
|
font-family: "MiSans VF";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 150 700;
|
font-weight: 150 700;
|
||||||
font-display: swap;
|
font-display: optional;
|
||||||
src: url("MiSans VF.woff2") format("woff2");
|
src: url("MiSans VF.woff2") format("woff2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,21 @@
|
|||||||
import { Header } from "@/components/shell/Header";
|
import { Header } from "@/components/shell/Header";
|
||||||
|
import { fetcher } from "@/lib/net";
|
||||||
|
import { UserResponse } from "@backend/src/schema";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
export default function Home() {
|
export default async function Home() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionID = cookieStore.get("session_id");
|
||||||
|
let user: undefined | UserResponse = undefined;
|
||||||
|
if (sessionID) {
|
||||||
|
try {
|
||||||
|
user = await fetcher<UserResponse>(`${process.env.BACKEND_URL}/user/session/${sessionID.value}`);
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header user={user} />
|
||||||
<main className="flex flex-col items-center justify-center h-full flex-grow gap-8 px-4">
|
<main className="flex flex-col items-center justify-center h-full flex-grow gap-8 px-4">
|
||||||
<h1 className="text-4xl font-medium text-center">正在施工中……</h1>
|
<h1 className="text-4xl font-medium text-center">正在施工中……</h1>
|
||||||
<p>在搜索栏输入BV号或AV号,可以查询目前数据库收集到的信息~</p>
|
<p>在搜索栏输入BV号或AV号,可以查询目前数据库收集到的信息~</p>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Dispatch, JSX, SetStateAction } from "react";
|
import { Dispatch, JSX, SetStateAction } from "react";
|
||||||
import { ApiRequestError, fetcher } from "@/lib/net";
|
import { ApiRequestError, fetcher } from "@/lib/net";
|
||||||
import type { CaptchaVerificationRawResponse, ErrorResponse, SignUpResponse } from "@backend/src/schema";
|
import type { CaptchaVerificationRawResponse, ErrorResponse, SignUpResponse } from "@backend/src/schema";
|
||||||
import Link from "next/link";
|
import { Link } from "@/i18n/navigation";
|
||||||
import { LocalizedMessage } from "./SignUpForm";
|
import { LocalizedMessage } from "./SignUpForm";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
import { string, object, ValidationError } from "yup";
|
import { string, object, ValidationError } from "yup";
|
||||||
|
@ -16,8 +16,13 @@ import { InfoIcon } from "@/components/icons/InfoIcon";
|
|||||||
import { HomeIcon } from "@/components/icons/HomeIcon";
|
import { HomeIcon } from "@/components/icons/HomeIcon";
|
||||||
import { TextButton } from "@/components/ui/Buttons/TextButton";
|
import { TextButton } from "@/components/ui/Buttons/TextButton";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { UserResponse } from "@backend/src/schema";
|
||||||
|
|
||||||
export const HeaderDestop = () => {
|
interface HeaderProps {
|
||||||
|
user?: UserResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeaderDestop = ({ user }: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="hidden md:flex relative top-0 left-0 w-full h-28 z-20 justify-between">
|
<div className="hidden md:flex relative top-0 left-0 w-full h-28 z-20 justify-between">
|
||||||
<div className="w-[305px] xl:ml-8 inline-flex items-center">
|
<div className="w-[305px] xl:ml-8 inline-flex items-center">
|
||||||
@ -37,14 +42,19 @@ export const HeaderDestop = () => {
|
|||||||
className="inline-flex relative gap-6 h-full lg:right-12
|
className="inline-flex relative gap-6 h-full lg:right-12
|
||||||
text-xl font-medium items-center w-[15rem] min-w-[8rem] mr-4 lg:mr-0 lg:w-[305px] justify-end"
|
text-xl font-medium items-center w-[15rem] min-w-[8rem] mr-4 lg:mr-0 lg:w-[305px] justify-end"
|
||||||
>
|
>
|
||||||
<a href="/signup">注册</a>
|
{user ? (
|
||||||
|
<Link href="/my/profile">{user.nickname || user.username}</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/signup">注册</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<a href="/about">关于</a>
|
<a href="/about">关于</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HeaderMobile = () => {
|
export const HeaderMobile = ({ user }: HeaderProps) => {
|
||||||
const [showDrawer, setShowDrawer] = useState(false);
|
const [showDrawer, setShowDrawer] = useState(false);
|
||||||
const [showsearchBox, setShowsearchBox] = useState(false);
|
const [showsearchBox, setShowsearchBox] = useState(false);
|
||||||
|
|
||||||
@ -125,11 +135,11 @@ export const HeaderMobile = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = (props: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderDestop />
|
<HeaderDestop {...props} />
|
||||||
<HeaderMobile />
|
<HeaderMobile {...props} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user