Add login

This commit is contained in:
2024-05-18 20:30:15 +04:00
parent d7f152c46a
commit 2572c43733
24 changed files with 536 additions and 175 deletions

70
package-lock.json generated
View File

@@ -8,12 +8,18 @@
"name": "torrent_frontend", "name": "torrent_frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.4.0",
"@types/js-cookie": "^3.0.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"next": "14.2.3", "next": "14.2.3",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.51.4",
"react-responsive-masonry": "^2.2.0", "react-responsive-masonry": "^2.2.0",
"swr": "^2.2.5",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -108,6 +114,14 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@hookform/resolvers": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.4.0.tgz",
"integrity": "sha512-+oAqK3okmoEDnvUkJ3N/mvNMeeMv5Apgy1jkoRmlaaAF4vBgcJs9tHvtXU7VE4DvPosvAUUkPOaNFunzt1dbgA==",
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.14", "version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@@ -447,6 +461,11 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="
},
"node_modules/@types/json5": { "node_modules/@types/json5": {
"version": "0.0.29", "version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -2880,6 +2899,14 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2942,6 +2969,14 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3749,6 +3784,21 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.51.4",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz",
"integrity": "sha512-V14i8SEkh+V1gs6YtD0hdHYnoL4tp/HX/A45wWQN15CYr9bFRmmRdYStSO5L65lCCZRF+kYiSKhm9alqbcdiVA==",
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -4346,6 +4396,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/swr": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
"integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
"dependencies": {
"client-only": "^0.0.1",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz",
@@ -4606,6 +4668,14 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
"integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -9,12 +9,18 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.4.0",
"@types/js-cookie": "^3.0.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"next": "14.2.3", "next": "14.2.3",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.51.4",
"react-responsive-masonry": "^2.2.0", "react-responsive-masonry": "^2.2.0",
"swr": "^2.2.5",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,3 +1,74 @@
"use client";
import {
LoginForm,
loginFormFieldNames,
loginFormSchema,
} from "@/entities/user";
import { UserService } from "@/entities/user/user";
import { Modal } from "@/shared/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { SubmitHandler, useForm } from "react-hook-form";
import { mutate } from "swr";
export default function Login() { export default function Login() {
return <>123</>; const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({ resolver: zodResolver(loginFormSchema) });
const router = useRouter();
const onSubmit: SubmitHandler<LoginForm> = async (data) => {
const userInfo = await UserService.Login(data);
mutate("user", userInfo);
router.back();
};
return (
<Modal>
<div className="">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col items-center justify-evenly"
>
<h2 className="pb-4 text-4xl">.Torrent</h2>
{(["username", "password"] as ("username" | "password")[]).map(
(field) => (
<label
className="flex flex-col items-start relative w-64 py-1"
key={field}
>
<input
{...register(field)}
className="peer/search w-full rounded-lg bg-bg4 px-2 h-10"
placeholder=" "
autoComplete="off"
/>
<span
className="peer-focus/search:opacity-0
peer-[:not(:placeholder-shown)]/search:opacity-0
transition-opacity h-0 flex items-center relative bottom-5 left-4
text-lg"
>
{loginFormFieldNames[field]}
</span>
<p className="text-sm text-err w-full text-center">
{errors[field]?.message}
</p>
</label>
)
)}
<input
type="submit"
value="Войти"
className="bg-ac0 mt-2 p-1 px-4 rounded-lg"
/>
</form>
</div>
</Modal>
);
} }

View File

@@ -1,3 +0,0 @@
export default function CatchAll() {
return null;
}

View File

@@ -15,6 +15,8 @@
--color-ac1: #fabd2f; --color-ac1: #fabd2f;
--color-ac2: #8ec07c; --color-ac2: #8ec07c;
--color-err: #cc241d;
--app-width: 70%; --app-width: 70%;
font-size: calc((100vw / 1920) * 20); font-size: calc((100vw / 1920) * 20);
} }
@@ -31,6 +33,8 @@
--color-ac0: #076678; --color-ac0: #076678;
--color-ac1: #b57614; --color-ac1: #b57614;
--color-ac2: #427b58; --color-ac2: #427b58;
--color-err: #cc241d;
} }
html, html,
@@ -45,7 +49,7 @@ body {
transition-property: color, background-color, border-color; transition-property: color, background-color, border-color;
transition-duration: 0.3s; transition-duration: 0.3s;
/* overflow: hidden; */ overflow: hidden;
color: var(--color-fg1); color: var(--color-fg1);
background-color: var(--color-bg0); background-color: var(--color-bg0);
} }

View File

@@ -6,24 +6,24 @@ import { Header } from "@/widgets/header";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export default function RootLayout({ export default function RootLayout({
auth, auth,
children, children,
}: Readonly<{ }: Readonly<{
auth: React.ReactNode; auth: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
// suppressHydrationWarning for theme support // suppressHydrationWarning for theme support
<html lang="ru" suppressHydrationWarning> <html lang="ru" suppressHydrationWarning>
<body className={inter.className}> <body className={inter.className}>
<ThemeProvider enableSystem={false} defaultTheme="light"> <ThemeProvider enableSystem={false} defaultTheme="light">
{auth} {auth}
<Header /> <Header />
<div className="w-full h-full max-w-[var(--app-width)] m-auto"> <div className="w-full h-full max-w-[var(--app-width)] m-auto overflow-y-auto">
{children} {children}
</div> </div>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
); );
} }

View File

@@ -1,3 +1,5 @@
import { redirect } from "next/navigation";
export default function Login() { export default function Login() {
return <>login page</>; redirect("/");
} }

View File

@@ -1,4 +1,4 @@
import { HTTPService } from "@/shared/http/httpService"; import { HTTPService } from "@/shared/utils/http";
import { gameCardsSchema, GameCardType } from "./schemas/gameCard"; import { gameCardsSchema, GameCardType } from "./schemas/gameCard";
import { gameSchema, GameType } from "./schemas/game"; import { gameSchema, GameType } from "./schemas/game";

View File

@@ -1,35 +1,35 @@
import { z } from "zod"; import { z } from "zod";
export const gameCardSchema = z export const gameCardSchema = z
.object({ .object({
id: z.number(), id: z.number().positive(),
title: z.string().min(3), title: z.string().min(3),
cover: z.string().optional(), cover: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
version: z.string().optional(), version: z.string().optional(),
}) })
.transform((card) => { .transform((card) => {
return { return {
...card, ...card,
cover: card.cover cover: card.cover
? process.env.NEXT_PUBLIC_COVER_FULL_URL + "/" + card.cover ? process.env.NEXT_PUBLIC_COVER_FULL_URL + "/" + card.cover
: undefined, : undefined,
cover_preview: card.cover cover_preview: card.cover
? process.env.NEXT_PUBLIC_COVER_PREVIEW_URL + "/" + card.cover ? process.env.NEXT_PUBLIC_COVER_PREVIEW_URL + "/" + card.cover
: undefined, : undefined,
}; };
}); });
export type GameCardType = z.infer<typeof gameCardSchema>; export type GameCardType = z.infer<typeof gameCardSchema>;
export const isGameCard = (a: any): a is GameCardType => { export const isGameCard = (a: any): a is GameCardType => {
return gameCardSchema.safeParse(a).success; return gameCardSchema.safeParse(a).success;
}; };
export const gameCardsSchema = z.array(z.any()).transform((a) => { export const gameCardsSchema = z.array(z.any()).transform((a) => {
const cards: GameCardType[] = []; const cards: GameCardType[] = [];
a.forEach((e) => { a.forEach((e) => {
if (isGameCard(e)) cards.push(gameCardSchema.parse(e)); if (isGameCard(e)) cards.push(gameCardSchema.parse(e));
else console.error("GameCard parse error - ", e); else console.error("GameCard parse error - ", e);
}); });
return cards; return cards;
}); });

View File

@@ -0,0 +1,16 @@
import {
loginFormSchema,
loginFormFieldNames,
LoginForm,
} from "./schemas/auth";
import { userSchema, User } from "./schemas/user";
import { UserService } from "./user";
export {
loginFormSchema,
loginFormFieldNames,
UserService,
userSchema,
type User,
type LoginForm,
};

View File

@@ -0,0 +1,30 @@
import { z } from "zod";
import { userSchema } from "./user";
export const loginFormSchema = z.object({
username: z.string().min(3, "Логин слишком короткий"),
password: z.string().min(3, "Пароль слишком короткий"),
});
export const loginFormFieldNames = {
username: "Логин",
password: "Пароль",
};
export type LoginForm = z.infer<typeof loginFormSchema>;
export const tokenResponseSchema = z
.object({
access_token: z.string(),
token_type: z.string(),
})
.transform((tokenResponse) => tokenResponse.access_token);
export type TokenResponse = z.infer<typeof tokenResponseSchema>;
export const tokenDataSchema = userSchema.merge(
z.object({
expire: z
.string()
.min(1)
.transform((d) => new Date(d)),
})
);
export type TokenData = z.infer<typeof tokenDataSchema>;

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const userSchema = z.object({
id: z.number().positive(),
name: z.string().min(3),
email: z.string().min(3),
});
export type User = z.infer<typeof userSchema>;

48
src/entities/user/user.ts Normal file
View File

@@ -0,0 +1,48 @@
import { HTTPService } from "@/shared/utils/http";
import {
LoginForm,
TokenData,
tokenDataSchema,
TokenResponse,
tokenResponseSchema,
} from "./schemas/auth";
import { jwtDecode } from "jwt-decode";
import Cookies from "js-cookie";
export abstract class UserService {
public static async Login(loginForm: LoginForm) {
const accessToken = await HTTPService.post<TokenResponse>(
"/auth",
new URLSearchParams(Object.entries(loginForm)),
tokenResponseSchema,
{
"Content-Type": "application/x-www-form-urlencoded",
}
);
if (accessToken) {
const tokenData = this.DecodeToken(accessToken);
if (tokenData) {
Cookies.set("access-token", accessToken, {
secure: true,
expires: tokenData.expire,
});
return tokenData;
}
}
}
public static IdentifyYourself(): TokenData | undefined {
const token = Cookies.get("access-token");
if (token) {
return this.DecodeToken(token);
}
}
public static DecodeToken(token: string): TokenData | undefined {
const tokenPayload = jwtDecode(token);
const parseResult = tokenDataSchema.safeParse(tokenPayload);
if (parseResult.success) {
return parseResult.data;
} else console.error("JWT payload broken - " + parseResult.error);
}
}

View File

@@ -0,0 +1,3 @@
import { UserActivities } from "./userActivities";
export { UserActivities };

View File

@@ -0,0 +1,29 @@
"use client";
import { UserService } from "@/entities/user";
import { PersonIcon } from "@/shared/assets/icons";
import Link from "next/link";
import useSWR from "swr";
export const UserActivities = () => {
const { data: me } = useSWR("user", () => UserService.IdentifyYourself());
return (
<>
<PersonIcon className="mr-1 h-4 w-4" />
{me && (
<span className="group/login cursor-pointer flex items-center">
<span className="group-hover/login:underline">{me.name}</span>
</span>
)}
{!me && (
<Link
href="/login"
className="group/login cursor-pointer flex items-center"
>
<span className="group-hover/login:underline">Войти</span>
</Link>
)}
</>
);
};

View File

@@ -1,34 +0,0 @@
import { z } from "zod";
export abstract class HTTPService {
public static async get<Z>(
url: string,
schema: z.ZodTypeAny
): Promise<Z | null> {
return await fetch(process.env.NEXT_PUBLIC_BASE_URL + url, {
method: "GET",
headers: {
accept: "application/json",
},
cache: "no-cache",
})
.then((r) => {
if (r && r.ok) return r;
else throw Error("Response ok = false");
})
.then((r) => r.json())
.then((d) => {
const parseResult = schema.safeParse(d);
if (parseResult.success) {
return parseResult.data as Z;
} else {
console.error(parseResult.error);
return null;
}
})
.catch((e) => {
console.error(e);
return null;
});
}
}

3
src/shared/ui/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import { Modal } from "./modal";
export { Modal };

33
src/shared/ui/modal.tsx Normal file
View File

@@ -0,0 +1,33 @@
"use client";
import clsx from "clsx";
import { useState } from "react";
import { useRouter } from "next/navigation";
export const Modal = ({ children }: { children: React.ReactNode }) => {
const [closing, setClosing] = useState(false);
const router = useRouter();
return (
<div
className={clsx(
!closing && "animate-fadeIn",
closing && "animate-fadeOut opacity-0",
"flex items-center justify-around",
"absolute z-20 left-0 w-full h-full bg-[#000000c5]"
)}
onClick={() => {
setClosing(true);
setTimeout(() => router.back(), 500);
}}
>
<div
className="rounded-lg bg-bg1 w-fit h-fit p-6"
onClick={(e) => {
e.stopPropagation();
}}
>
{children}
</div>
</div>
);
};

53
src/shared/utils/http.ts Normal file
View File

@@ -0,0 +1,53 @@
import { z } from "zod";
export abstract class HTTPService {
public static async get<Z>(
url: string,
schema: z.ZodTypeAny
): Promise<Z | null> {
return await fetch(process.env.NEXT_PUBLIC_BASE_URL + url, {
method: "GET",
headers: {
accept: "application/json",
},
cache: "no-cache",
})
.then((r) => {
if (r && r.ok) return r;
else throw Error("Response ok = false");
})
.then((r) => r.json())
.then((d) => schema.parse(d))
.catch((e) => {
console.error(e);
return null;
});
}
public static async post<Z>(
url: string,
body: BodyInit,
schema: z.ZodTypeAny,
headers?: HeadersInit
): Promise<Z | null> {
return await fetch(process.env.NEXT_PUBLIC_BASE_URL + url, {
method: "POST",
headers: {
accept: "application/json",
...headers,
},
body: body,
cache: "no-cache",
})
.then((r) => {
if (r && r.ok) return r;
else throw Error("Response ok = false");
})
.then((r) => r.json())
.then((d) => schema.parse(d))
.catch((e) => {
console.error(e);
return null;
});
}
}

View File

@@ -1,3 +1,3 @@
import { getYouTubeID } from "./get_youtube_id"; import { getYouTubeID } from "./getYoutubeId";
export { getYouTubeID }; export { getYouTubeID };

View File

@@ -1,74 +1,69 @@
"use client"; "use client";
import { SchemeSwitch } from "@/features/colorSchemeSwitch"; import { SchemeSwitch } from "@/features/colorSchemeSwitch";
import { PersonIcon, SearchIcon } from "@/shared/assets/icons"; import { SearchIcon } from "@/shared/assets/icons";
import { MobileMenu } from "./mobileMenu/mobileMenu"; import { MobileMenu } from "./mobileMenu/mobileMenu";
import Link from "next/link"; import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation"; import { useSelectedLayoutSegment } from "next/navigation";
import clsx from "clsx"; import clsx from "clsx";
import { UserActivities } from "@/features/userActivities";
const sections = [ const sections = [
{ title: "Игры", href: "games" }, { title: "Игры", href: "games" },
{ title: "Фильмы", href: "films" }, { title: "Фильмы", href: "films" },
{ title: "Аудиокниги", href: "audiobooks" }, { title: "Аудиокниги", href: "audiobooks" },
]; ];
export const Header = () => { export const Header = () => {
const currentPageName = useSelectedLayoutSegment(); const currentPageName = useSelectedLayoutSegment();
return ( return (
<header className="w-full h-20 bg-bg1 sticky top-0 shadow-xl"> <header className="w-full h-20 z-10 bg-bg1 sticky top-0 shadow-xl">
<div <div
className="w-full h-full max-w-[var(--app-width)] m-auto px-5 className="w-full h-full max-w-[var(--app-width)] m-auto px-5
flex items-center justify-between" flex items-center justify-between"
> >
<h1 className="text-4xl font-bold flex items-center"> <h1 className="text-4xl font-bold flex items-center">
<div className="lp:hidden"> <div className="lp:hidden">
<MobileMenu sections={sections} /> <MobileMenu sections={sections} />
</div> </div>
<Link href="/">.Torrent</Link> <Link href="/">.Torrent</Link>
</h1> </h1>
<div className="hidden text-2xl dsk:block"> <div className="hidden text-2xl lp:block">
{sections.map((section) => ( {sections.map((section) => (
<Link <Link
key={section.title} key={section.title}
className={clsx( className={clsx(
"px-5 cursor-pointer hover:underline underline-offset-2", "px-5 cursor-pointer hover:underline underline-offset-2",
currentPageName === section.href && "underline" currentPageName === section.href && "underline"
)} )}
href={section.href} href={section.href}
> >
{section.title} {section.title}
</Link> </Link>
))} ))}
</div> </div>
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="flex items-center mb-1 "> <span className="flex items-center mb-1 ">
<SchemeSwitch /> <SchemeSwitch />
<Link <UserActivities />
href="/login" </span>
className="group/login cursor-pointer flex items-center" <label className="flex flex-col items-start relative w-36">
> <input
<PersonIcon className="mr-1 h-4 w-4" /> className="peer/search w-full rounded-lg bg-bg4 px-2"
<span className="group-hover/login:underline">Войти</span> placeholder=" "
</Link> />
</span> <span
<label className="flex flex-col items-start relative w-36"> className="peer-focus/search:opacity-0
<input
className="peer/search w-full rounded-lg bg-bg4 px-2"
placeholder=" "
/>
<span
className="peer-focus/search:opacity-0
peer-[:not(:placeholder-shown)]/search:opacity-0 peer-[:not(:placeholder-shown)]/search:opacity-0
transition-opacity h-0 flex items-center relative bottom-3" transition-opacity h-0 flex items-center relative bottom-3"
> >
<SearchIcon className="w-4 h-4 mx-2" /> <SearchIcon className="w-4 h-4 mx-2" />
Поиск Поиск
</span> </span>
</label> </label>
</div> </div>
</div> </div>
</header> </header>
); );
}; };

View File

@@ -1,8 +1,11 @@
"use client"; "use client";
import clsx from "clsx";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry"; import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
import { boolean } from "zod";
export const Section = ({ export const Section = ({
name, name,
@@ -16,6 +19,9 @@ export const Section = ({
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const router = useRouter(); const router = useRouter();
const [loaded, setLoaded] = useState<boolean>(false);
useEffect(() => setLoaded(true), []);
return ( return (
<section className="w-full h-fit p-2 mb-20 pt-8"> <section className="w-full h-fit p-2 mb-20 pt-8">
@@ -27,7 +33,13 @@ export const Section = ({
{name} {name}
</h2> </h2>
)} )}
<ResponsiveMasonry columnsCountBreakPoints={{ 0: 1, 640: 2, 1024: 3 }}> <ResponsiveMasonry
className={clsx(
"transition-opacity duration-300 opacity-0",
loaded && "opacity-100"
)}
columnsCountBreakPoints={{ 0: 1, 640: 2, 1024: 3 }}
>
<Masonry gutter="1rem">{children}</Masonry> <Masonry gutter="1rem">{children}</Masonry>
</ResponsiveMasonry> </ResponsiveMasonry>
{link && invite_text && ( {link && invite_text && (

View File

@@ -1,37 +1,52 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
const config: Config = { const config: Config = {
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}",
], ],
theme: { theme: {
extend: { extend: {
backgroundImage: { backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))", "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic": "gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
}, },
colors: { colors: {
bg0: "var(--color-bg0)", bg0: "var(--color-bg0)",
bg1: "var(--color-bg1)", bg1: "var(--color-bg1)",
bg4: "var(--color-bg4)", bg4: "var(--color-bg4)",
fg0: "var(--color-fg0)", fg0: "var(--color-fg0)",
fg1: "var(--color-fg1)", fg1: "var(--color-fg1)",
fg4: "var(--color-fg4)", fg4: "var(--color-fg4)",
ac0: "var(--color-ac0)", ac0: "var(--color-ac0)",
ac1: "var(--color-ac1)", ac1: "var(--color-ac1)",
ac2: "var(--color-ac2)", ac2: "var(--color-ac2)",
}, err: "var(--color-err)",
}, },
screens: { animation: {
tb: "640px", fadeIn: "fadeIn 0.25s ease-in-out",
lp: "1024px", fadeOut: "fadeOut 0.25s ease-in-out",
dsk: "1280px", },
}, keyframes: () => ({
}, fadeIn: {
plugins: [], "0%": { opacity: "0" },
"100%": { opacity: "1" },
},
fadeOut: {
"0%": { opacity: "1" },
"100%": { opacity: "0" },
},
}),
},
screens: {
tb: "640px",
lp: "1024px",
dsk: "1280px",
},
},
plugins: [],
}; };
export default config; export default config;