Game create and edit form. Formatting (2 spaces)

This commit is contained in:
2024-06-11 14:33:28 +04:00
parent b7e137d798
commit 1374c2bd70
24 changed files with 795 additions and 564 deletions

View File

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

View File

@@ -4,27 +4,27 @@ import { GameInfo } from "@/widgets/gameInfo";
import { Section } from "@/widgets/section"; import { Section } from "@/widgets/section";
export default async function Games({ export default async function Games({
params: { game_id }, params: { game_id },
}: { }: {
params: { game_id: number }; params: { game_id: number };
}) { }) {
const gameCards = await GameService.getGameCards(); const gameCards = await GameService.GetGameCards();
const game = await GameService.getGame(game_id); const game = await GameService.GetGame(game_id);
return ( return (
<> <>
{game && <GameInfo game={game} />} {game && <GameInfo game={game} />}
{gameCards && ( {gameCards && (
<Section <Section
name="Популярные игры" name="Популярные игры"
link="/games" link="/games"
invite_text={'Перейти в раздел "Игры"'} invite_text={'Перейти в раздел "Игры"'}
> >
{gameCards.map((card) => ( {gameCards.map((card) => (
<GameCard key={card.id} card={card} /> <GameCard key={card.id} card={card} />
))} ))}
</Section> </Section>
)} )}
</> </>
); );
} }

View File

@@ -0,0 +1,26 @@
import { GameService } from "@/entities/game";
import { GameCard } from "@/features/gameCard";
import { GameInfo } from "@/widgets/gameInfo";
import { Section } from "@/widgets/section";
export default async function AddGame() {
const gameCards = await GameService.GetGameCards();
return (
<>
<GameInfo game={GameService.GetEmptyGame()} />
{gameCards && (
<Section
name="Популярные игры"
link="/games"
invite_text={'Перейти в раздел "Игры"'}
>
{gameCards.map((card) => (
<GameCard key={card.id} card={card} />
))}
</Section>
)}
</>
);
}

View File

@@ -10,7 +10,7 @@ export const metadata: Metadata = {
}; };
export default async function Games() { export default async function Games() {
const gameCards = await GameService.getGameCards(); const gameCards = await GameService.GetGameCards();
return ( return (
<> <>
{gameCards && gameCards.length > 0 && ( {gameCards && gameCards.length > 0 && (

View File

@@ -1,61 +1,61 @@
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: ".Torrent: Как скачать?", title: ".Torrent: Как скачать?",
description: description:
".Torrent: Как скачать? - краткое руководство по скачиваю данных с помощью .torrent файлов", ".Torrent: Как скачать? - краткое руководство по скачиваю данных с помощью .torrent файлов",
}; };
export default async function HowToDownload() { export default async function HowToDownload() {
return ( return (
<div className="w-full flex flex-col lp:flex-row justify-between p-4"> <div className="w-full flex flex-col lp:flex-row justify-between p-4">
<div className="w-full p-4 lp:w-[50%] lp:pr-10"> <div className="w-full p-4 lp:w-[50%] lp:pr-10">
<h1 className="text-4xl lp:text-3xl">Как скачать?</h1> <h1 className="text-4xl lp:text-3xl">Как скачать?</h1>
<div className="text-fg4 text-justify pt-2"> <div className="text-fg4 text-justify pt-2">
Чтобы скачать данные с помощью торрент-файла, выполните следующие Чтобы скачать данные с помощью торрент-файла, выполните следующие
шаги: шаги:
<ul className="*:text-fg4"> <ul className="*:text-fg4">
<li> <li>
1. Загрузите торрент-файл, содержащий информацию о файлах, которые 1. Загрузите торрент-файл, содержащий информацию о файлах, которые
вы хотите скачать с нашего сайта. вы хотите скачать с нашего сайта.
</li> </li>
<li> <li>
2. Откройте программу-клиент для загрузки торрентов, например, 2. Откройте программу-клиент для загрузки торрентов, например,
uTorrent, BitTorrent или qBittorrent. uTorrent, BitTorrent или qBittorrent.
</li> </li>
<li> <li>
3. В программе-клиенте выберите опцию "Open Torrent File" или "Add 3. В программе-клиенте выберите опцию &quot;Open Torrent
Torrent" и выберите торрент-файл, который вы скачали в первом File&quot; или &quot;Add Torrent&quot; и выберите торрент-файл,
шаге. который вы скачали в первом шаге.
</li> </li>
<li> <li>
4. После этого начнется загрузка файлов, указанных в 4. После этого начнется загрузка файлов, указанных в
торрент-файле. Вы также можете выбрать папку, куда сохранять торрент-файле. Вы также можете выбрать папку, куда сохранять
скачанные файлы. скачанные файлы.
</li> </li>
<li> <li>
5. Дождитесь завершения загрузки файлов. После этого вы сможете 5. Дождитесь завершения загрузки файлов. После этого вы сможете
открыть и использовать скачанные файлы на своем компьютере. открыть и использовать скачанные файлы на своем компьютере.
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="w-full p-4 lp:w-[50%] lp:pl-10"> <div className="w-full p-4 lp:w-[50%] lp:pl-10">
<h2 className="text-4xl lp:text-3xl">Что такое .torrent файл?</h2> <h2 className="text-4xl lp:text-3xl">Что такое .torrent файл?</h2>
<p className="text-fg4 text-justify pt-2"> <p className="text-fg4 text-justify pt-2">
Торрент-файл (или .torrent-файл) - это небольшой файл, который Торрент-файл (или .torrent-файл) - это небольшой файл, который
содержит метаданные о файле или наборе файлов, которые можно загрузить содержит метаданные о файле или наборе файлов, которые можно загрузить
с помощью протокола BitTorrent. В торрент-файле обычно указан адрес с помощью протокола BitTorrent. В торрент-файле обычно указан адрес
трекера (специального сервера, отслеживающего пиров) и хеш-суммы трекера (специального сервера, отслеживающего пиров) и хеш-суммы
частей файлов, которые необходимы для скачивания. частей файлов, которые необходимы для скачивания.
<br /> <br />
<br /> Пользователь, желающий загрузить файл через BitTorrent, сначала <br /> Пользователь, желающий загрузить файл через BitTorrent, сначала
скачивает торрент-файл или magnet-ссылку, загружает ее в скачивает торрент-файл или magnet-ссылку, загружает ее в
торрент-клиент (программу для скачивания торрентов), и затем начинает торрент-клиент (программу для скачивания торрентов), и затем начинает
загрузку файлов, участвуя в обмене данными с другими пользователями загрузку файлов, участвуя в обмене данными с другими пользователями
(пирами) через сеть BitTorrent. (пирами) через сеть BitTorrent.
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@@ -6,27 +6,27 @@ 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 <div
className="w-full h-[calc(100%_-_5rem)] \ className="w-full h-[calc(100%_-_5rem)] \
max-w-[var(--app-width)] m-auto overflow-y-auto" max-w-[var(--app-width)] m-auto overflow-y-auto"
> >
{children} {children}
</div> </div>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
); );
} }

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default function Login() { export default function Login() {
redirect("/"); redirect("/");
} }

View File

@@ -4,26 +4,26 @@ import { Section } from "@/widgets/section";
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: ".Torrent", title: ".Torrent",
description: description:
".Torrent - сервис обмена .torrent файлами видеоигр, фильмов и аудиокниг", ".Torrent - сервис обмена .torrent файлами видеоигр, фильмов и аудиокниг",
}; };
export default async function Home() { export default async function Home() {
const gameCards = await GameService.getGameCards(); const gameCards = await GameService.GetGameCards();
return ( return (
<> <>
{gameCards && gameCards.length > 0 && ( {gameCards && gameCards.length > 0 && (
<Section <Section
name="Игры" name="Игры"
link="/games" link="/games"
invite_text={'Перейти в раздел "Игры"'} invite_text={'Перейти в раздел "Игры"'}
> >
{gameCards.map((card) => ( {gameCards.map((card) => (
<GameCard key={card.id} card={card} /> <GameCard key={card.id} card={card} />
))} ))}
</Section> </Section>
)} )}
</> </>
); );
} }

View File

@@ -13,4 +13,16 @@ export abstract class FilesService {
false false
); );
} }
public static async UploadTorrent(torrent: File) {
const formData = new FormData();
formData.append("torrent", torrent);
return await HTTPService.post(
`/files/torrent`,
coverNameSchema,
formData,
{},
false
);
}
} }

View File

@@ -1,16 +1,25 @@
import { HTTPService } from "@/shared/utils/http"; import { HTTPService } from "@/shared/utils/http";
import { gameCardsSchema, GameCardType } from "./schemas/gameCard"; import { gameCardsSchema } from "./schemas/gameCard";
import { GameCreateType, gameSchema } from "./schemas/game"; import { GameCreateType, gameSchema } from "./schemas/game";
import { z } from "zod";
export abstract class GameService { export abstract class GameService {
public static async getGameCards() { public static async GetGameCards() {
return await HTTPService.get("/games/cards", gameCardsSchema); return await HTTPService.get("/games/cards", gameCardsSchema);
} }
public static async getGame(id: number) { public static async GetGame(id: number) {
return await HTTPService.get(`/games/${id}`, gameSchema); return await HTTPService.get(`/games/${id}`, gameSchema);
} }
public static async changeGame(id: number, gameInfo: GameCreateType) { public static async ChangeGame(id: number, gameInfo: GameCreateType) {
return await HTTPService.put(`/games/${id}`, gameSchema, gameInfo); return await HTTPService.put(`/games/${id}`, gameSchema, gameInfo);
} }
public static async AddGame(gameInfo: GameCreateType) {
return await HTTPService.post(`/games`, gameSchema, gameInfo);
}
public static GetEmptyGame(): GameCreateType {
return {
title: "",
torrent_file: "",
};
}
} }

View File

@@ -3,9 +3,9 @@ import { GameType, GameCreateType, gameCreateSchema } from "./schemas/game";
import { GameService } from "./game"; import { GameService } from "./game";
export { export {
GameService, GameService,
gameCreateSchema, gameCreateSchema,
type GameType, type GameType,
type GameCreateType, type GameCreateType,
type GameCardType, type GameCardType,
}; };

View File

@@ -3,23 +3,30 @@ import { gameCardBaseSchema } from "./gameCard";
export const gameBaseSchema = gameCardBaseSchema.merge( export const gameBaseSchema = gameCardBaseSchema.merge(
z.object({ z.object({
torrent_file: z.string().min(1), torrent_file: z.string().min(3, "У раздачи должен быть .torrent файл"),
trailer: z.string().optional(), trailer: z.string().optional(),
system: z.string().optional(), system: z.string().optional().nullable(),
processor: z.string().optional(), processor: z.string().optional().nullable(),
memory: z.string().optional(), memory: z.string().optional().nullable(),
graphics: z.string().optional(), graphics: z.string().optional().nullable(),
storage: z.string().optional(), storage: z.string().optional().nullable(),
developer: z.string().optional(), developer: z.string().optional().nullable(),
language: z.string().optional(), language: z.string().optional().nullable(),
download_size: z.string().optional(), download_size: z.string().optional().nullable(),
release_date: z release_date: z
.string() .string()
.min(1) .optional()
.transform((d) => new Date(d)), .nullable()
.transform((d) =>
d
? new Date(d).toLocaleDateString("en-us", {
year: "numeric",
})
: undefined
),
}) })
); );

View File

@@ -1,10 +1,10 @@
import { z } from "zod"; import { z } from "zod";
export const gameCardBaseSchema = z.object({ export const gameCardBaseSchema = z.object({
title: z.string().min(3), title: z.string().min(3, "Слишком короткое название"),
cover: z.string().optional(), cover: z.string().optional().nullable(),
description: z.string().optional(), description: z.string().optional().nullable(),
version: z.string().optional(), version: z.string().optional().nullable(),
}); });
export const gameCardSchema = gameCardBaseSchema.merge( export const gameCardSchema = gameCardBaseSchema.merge(

View File

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

View File

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

View File

@@ -4,14 +4,14 @@ import { SunIcon } from "@/shared/assets/icons";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
export const ColorSchemeSwitch = () => { export const ColorSchemeSwitch = () => {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
return ( return (
<> <>
<SunIcon <SunIcon
className="mr-5 h-8 w-8 cursor-pointer" className="mr-5 h-8 w-8 cursor-pointer"
onClick={() => setTheme(theme == "light" ? "dark" : "light")} onClick={() => setTheme(theme == "light" ? "dark" : "light")}
/> />
</> </>
); );
}; };

View File

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

View File

@@ -1,6 +1,6 @@
export const getYouTubeID = (url: string) => { export const getYouTubeID = (url: string) => {
const regExp = const regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/; /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
const match = url.match(regExp); const match = url.match(regExp);
return match && match[7].length == 11 ? match[7] : false; return match && match[7].length == 11 ? match[7] : false;
}; };

View File

@@ -31,7 +31,11 @@ export abstract class HTTPService {
else throw Error("Response ok = false"); else throw Error("Response ok = false");
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => schema.parse(d) as z.infer<Z>) .then((d) => {
const parsed = schema.safeParse(d);
if (parsed.success) return parsed.data as z.infer<Z>;
else throw new Error(parsed.error.message);
})
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
return null; return null;

View File

@@ -11,7 +11,7 @@ import Link from "next/link";
import { getYouTubeID } from "@/shared/utils"; import { getYouTubeID } from "@/shared/utils";
import { UserService } from "@/entities/user"; import { UserService } from "@/entities/user";
import useSWR from "swr"; import useSWR from "swr";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Img } from "@/shared/ui"; import { Img } from "@/shared/ui";
@@ -19,28 +19,44 @@ import { useDropzone } from "react-dropzone";
import { FilesService } from "@/entities/files"; import { FilesService } from "@/entities/files";
import { SpinnerIcon } from "@/shared/assets/icons"; import { SpinnerIcon } from "@/shared/assets/icons";
const propertyUnknownText = "Не известно"; const isExistingGame = (game: GameCreateType | GameType): game is GameType => {
return (game as GameType).id !== undefined;
};
export const GameInfo = ({
game: init_game,
}: {
game: GameCreateType | GameType;
}) => {
const [game, changeGame] = useState<GameCreateType | GameType>(init_game);
export const GameInfo = ({ game }: { game: GameType }) => {
const { data: me } = useSWR("user", () => UserService.IdentifyYourself()); const { data: me } = useSWR("user", () => UserService.IdentifyYourself());
const [editable, setEditable] = useState<boolean>(false); const [editable, setEditable] = useState<boolean>(false);
useEffect(() => setEditable(me?.id === game.owner_id), [me, game]); useEffect(() => {
if (me) {
if (isExistingGame(game)) setEditable(me.id === game.owner_id);
else setEditable(true);
}
}, [me, game]);
const formRef = useRef<HTMLFormElement>(null);
const { const {
register, register,
handleSubmit, handleSubmit,
setValue, setValue,
watch, watch,
reset,
formState: { dirtyFields, errors }, formState: { dirtyFields, errors },
} = useForm<GameCreateType>({ } = useForm<GameCreateType>({
defaultValues: game,
resolver: zodResolver(gameCreateSchema), resolver: zodResolver(gameCreateSchema),
}); });
useEffect(() => { useEffect(() => {
register("torrent_file", { value: game.torrent_file }); register("torrent_file", { value: game.torrent_file });
register("cover", { value: game.cover }); register("cover", { value: game.cover });
}, []); }, [game.cover, game.torrent_file, register]);
const [savedTimeout, changeSavedTimeout] = useState<NodeJS.Timeout | null>( const [savedTimeout, changeSavedTimeout] = useState<NodeJS.Timeout | null>(
null null
@@ -48,6 +64,7 @@ export const GameInfo = ({ game }: { game: GameType }) => {
const watchedData = watch(); const watchedData = watch();
const [formData, changeFormData] = useState<GameCreateType | null>(null); const [formData, changeFormData] = useState<GameCreateType | null>(null);
useEffect(() => { useEffect(() => {
console.log(watchedData);
if (!Object.keys(dirtyFields).length) return; if (!Object.keys(dirtyFields).length) return;
if (JSON.stringify(watchedData) === JSON.stringify(formData)) return; if (JSON.stringify(watchedData) === JSON.stringify(formData)) return;
console.log(dirtyFields); console.log(dirtyFields);
@@ -55,76 +72,141 @@ export const GameInfo = ({ game }: { game: GameType }) => {
if (savedTimeout) clearTimeout(savedTimeout); if (savedTimeout) clearTimeout(savedTimeout);
changeSavedTimeout( changeSavedTimeout(
setTimeout(() => { setTimeout(() => {
console.log("call", formRef.current);
if (formRef.current) formRef.current.requestSubmit(); if (formRef.current) formRef.current.requestSubmit();
}, 5000) }, 3000)
); );
}, [watchedData]); }, [watchedData]);
const onSubmit = async (formData: GameCreateType) => { const onSubmit = async (formData: GameCreateType) => {
changeSavedTimeout(null); changeSavedTimeout(null);
const updatedGame = await GameService.changeGame(game.id, formData); if (isExistingGame(game)) {
console.log(updatedGame); const updatedGame = await GameService.ChangeGame(game.id, formData);
if (updatedGame) {
changeGame(updatedGame);
reset({}, { keepValues: true });
}
} else {
const addedGame = await GameService.AddGame(formData);
if (addedGame) {
changeGame(addedGame);
reset({}, { keepValues: true });
}
}
}; };
const [cover, setCover] = useState<string | undefined>(game.cover); const onCoverDrop = useCallback(
(acceptedFiles: File[]) => {
const file = acceptedFiles[0];
const fileReader = new FileReader();
fileReader.onload = async () => {
const coverName = await FilesService.UploadCover(file);
if (coverName) {
setValue("cover", coverName, {
shouldValidate: true,
shouldDirty: true,
});
}
};
fileReader.readAsDataURL(file);
},
[setValue]
);
const onDrop = useCallback((acceptedFiles: File[]) => { const {
const file = acceptedFiles[0]; getRootProps: getCoverDropRootProps,
const fileReader = new FileReader(); getInputProps: getCoverDropInputProps,
fileReader.onload = async () => { isDragActive: isCoverDragActive,
const coverName = await FilesService.UploadCover(file); } = useDropzone({ onDrop: onCoverDrop });
if (coverName) {
setCover(coverName);
setValue("cover", coverName);
}
};
fileReader.readAsDataURL(file);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }); const onTorrentDrop = useCallback(
(acceptedFiles: File[]) => {
const file = acceptedFiles[0];
const fileReader = new FileReader();
fileReader.onload = async () => {
const torrentName = await FilesService.UploadTorrent(file);
if (torrentName) {
setValue("torrent_file", torrentName, {
shouldValidate: true,
shouldDirty: true,
});
}
};
fileReader.readAsDataURL(file);
},
[setValue]
);
const formRef = useRef<HTMLFormElement>(null); useEffect(() => console.log(errors), [errors]);
const {
getRootProps: getTorrentDropRootProps,
getInputProps: getTorrentDropInputProps,
isDragActive: isTorrentDragActive,
} = useDropzone({ onDrop: onTorrentDrop });
const [trailer, setTrailer] = useState<string | undefined>(game.trailer);
return ( return (
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="p-4 flex flex-col lp:block" className="m-4 flex flex-col lp:block"
ref={formRef} ref={formRef}
> >
{cover && ( {(watchedData.cover || editable) && (
<div <div className="lp:w-[60%] lp:px-4 lp:pl-0 pt-2 pb-4 float-left relative">
className="lp:w-[60%] lp:px-4 lp:pl-0 pt-2 float-left" {watchedData.cover && (
{...(editable ? getRootProps() : {})} <Img
> src={watchedData.cover}
<Img preview={false}
src={cover} className="transition-all rounded-lg w-full object-contain"
preview={false} width={1280}
className="transition-all rounded-lg w-full object-contain" height={720}
width={1280} />
height={720} )}
/> {!watchedData.cover && editable && (
<div className="w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
)}
{editable && ( {editable && (
<> <div
<input {...getInputProps()} /> className="absolute h-full w-full mt-1 left-0 top-0 z-20 flex items-end"
<span className="flex items-center ju w-full p-1"> {...(editable ? getCoverDropRootProps() : {})}
{isDragActive ? ( >
<p>Изменить обложку...</p> <input {...getCoverDropInputProps()} />
<span className="flex items-center ju w-full">
{isCoverDragActive ? (
<p className="w-full text-sm pl-2">Изменить обложку...</p>
) : ( ) : (
<span className="text-sm w-full flex justify-around"> <>
Для редактирования нажмите или перетащите новую обложку <span className="hidden lp:flex text-sm w-full justify-around text-fg4">
поверх старой Для редактирования нажмите или перетащите новую обложку
</span> поверх старой
</span>
<span className="text-xs lp:hidden w-full flex justify-around text-fg4">
Для редактирования нажмите на обложку и выберите фото
</span>
</>
)} )}
</span> </span>
</> </div>
)} )}
</div> </div>
)} )}
<span className="lp:max-w-[40%]"> <span>
<span className="flex items-end justify-between"> <span className="flex items-end justify-between relative pt-2">
<span
className={clsx(
"text-fg4 text-2xl absolute -z-10 opacity-0",
watchedData.title === "" && "opacity-100",
"transition-opacity cursor-text"
)}
>
Введите название
</span>
<h1 <h1
className={clsx( className={clsx(
"text-4xl outline-none max-w-[80%]", "text-4xl outline-none max-w-[80%] cursor-text",
!editable && "cursor-default" !editable && "cursor-default"
)} )}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
@@ -135,41 +217,61 @@ export const GameInfo = ({ game }: { game: GameType }) => {
shouldValidate: true, shouldValidate: true,
shouldDirty: true, shouldDirty: true,
}); });
console.log();
}} }}
> >
{game.title} {game.title}
</h1> </h1>
<span className="text-sm text-fg4 flex items-center">
{savedTimeout && (
<>
<SpinnerIcon className="mr-2" />
Редактируется
</>
)}
{!savedTimeout && "Сохранено"}
</span>
</span>
{game.description && ( {editable && (
<div <span className="text-sm text-fg4 flex items-center cursor-default">
contentEditable={editable} {savedTimeout && Object.keys(errors).length === 0 && (
suppressContentEditableWarning={true} <>
className={clsx( <SpinnerIcon className="mr-2" />
"text-md text-justify", Редактируется
"text-fg4 pt-2 outline-none", </>
!editable && "cursor-default" )}
)} {savedTimeout && Object.keys(errors).length > 0 && (
{...register("description", { value: game.description })} <span className="text-err text-right">Некорректные данные</span>
onInput={(e) => { )}
setValue("description", e.currentTarget.innerText, { {!savedTimeout && "Сохранено"}
shouldValidate: true, </span>
shouldDirty: true, )}
}); </span>
}} <div className="text-err text-xs w-full h-2">
> {errors.title?.message}
{game.description} </div>
</div> {(game.description || editable) && (
<span className="relative">
<span
className={clsx(
"text-fg4 text-md absolute -z-10 opacity-0",
(watchedData.description === "" ||
watchedData.description === undefined) &&
"opacity-100",
"transition-opacity mt-2"
)}
>
Введите описание
</span>
<div
contentEditable={editable}
suppressContentEditableWarning={true}
className={clsx(
"text-md text-justify",
"text-fg4 pt-2 outline-none",
!editable && "cursor-default"
)}
{...register("description", { value: game.description })}
onInput={(e) => {
setValue("description", e.currentTarget.innerText, {
shouldValidate: true,
shouldDirty: true,
});
}}
>
{game.description}
</div>
</span>
)} )}
</span> </span>
<div <div
@@ -192,71 +294,142 @@ export const GameInfo = ({ game }: { game: GameType }) => {
{ {
name: "Дата обновления раздачи", name: "Дата обновления раздачи",
key: "update_date", key: "update_date",
value: game.update_date.toLocaleDateString("ru-ru"), value: isExistingGame(game)
? game.update_date.toLocaleDateString("ru-ru")
: new Date().toLocaleDateString("ru-ru"),
edit: false,
}, },
{ name: "Язык", key: "language" }, { name: "Язык", key: "language" },
{ name: "Разработчик", key: "developer" }, { name: "Разработчик", key: "developer" },
{ {
name: "Год выхода", name: "Год выхода",
key: "release_date", key: "release_date",
value: game.release_date.toLocaleDateString("en-us", {
year: "numeric",
}),
}, },
{ name: "Объём загрузки", key: "download_size" }, { name: "Объём загрузки", key: "download_size" },
], ],
] as { name: string; key: keyof GameCreateType; value?: string }[][] ] as {
name: string;
key: keyof GameCreateType;
value?: string;
edit?: boolean;
}[][]
).map((section, i) => ( ).map((section, i) => (
<ul key={i} className="w-[48%] bg-bg1 rounded-lg py-1 px-4"> <ul key={i} className="w-[48%] bg-bg1 rounded-lg py-1 px-4">
{section.map((req) => ( {section.map((req) => (
<li key={req.name} className="font-bold text-sm lp:text-md py-1"> <li key={req.name} className="text-sm lp:text-md py-1">
{req.name + ": "} <span className="font-bold">{req.name + ": "}</span>
<input <span className="relative">
readOnly={!editable} <span
className={clsx( className={clsx(
"font-normal outline-none bg-bg1", "text-fg4 opacity-0 absolute -z-10 text-xs",
req.value === undefined && (watchedData[req.key] === undefined ||
(game[req.key] === undefined || watchedData[req.key] === null ||
game[req.key] === propertyUnknownText) && (watchedData[req.key] as string) === "") &&
"text-fg4", "opacity-100 relative !z-10 text-base",
!editable && "cursor-default" "transition-opacity "
)} )}
{...register(req.key, { >
value: Не известно
req.value ?? </span>
(game[req.key] as string) ?? <span
propertyUnknownText, className={clsx(
})} "outline-none",
defaultValue={ !editable && "cursor-default",
req.value ?? (watchedData[req.key] === undefined ||
(game[req.key] as string) ?? watchedData[req.key] === null ||
propertyUnknownText (watchedData[req.key] as string) === "") &&
} "opacity-100 absolute left-0 top-0 inline-block min-w-10 z-10"
onBlur={(e) => { )}
if (e.target.value === "") { {...register(req.key, {
e.target.value = propertyUnknownText; value: req.value ?? (game[req.key] as string),
} })}
}} contentEditable={editable && (req.edit ?? true)}
></input> suppressContentEditableWarning={true}
onInput={(e) => {
setValue(req.key, e.currentTarget.innerText, {
shouldValidate: true,
shouldDirty: true,
});
}}
>
{req.value ?? (game[req.key] as string)}
</span>
</span>
</li> </li>
))} ))}
</ul> </ul>
))} ))}
</div> </div>
{game.trailer && getYouTubeID(game.trailer) && ( {(trailer || editable) && (
<iframe <div className="w-ful aspect-video">
src={"https://youtube.com/embed/" + getYouTubeID(game.trailer)} {trailer && getYouTubeID(trailer) && (
className="w-full aspect-video rounded-lg mt-4" <iframe
allowFullScreen src={"https://youtube.com/embed/" + getYouTubeID(trailer)}
/> className="w-full aspect-video rounded-lg mt-4"
allowFullScreen
/>
)}
{!trailer && editable && (
<div className="mt-4 w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
)}
</div>
)} )}
<div className="relative w-full flex items-center justify-around pt-4">
<Link {editable && (
href={process.env.NEXT_PUBLIC_CONTENT_URL + "/" + game.torrent_file} <div className="w-full flex justify-end pt-1">
className="p-4 bg-ac0 text-fg1 text-2xl rounded-lg" <input
> className="outline-none w-full lp:w-2/3 text-xs lp:text-base bg-bg1"
Скачать {game.title} {...register("trailer", {
</Link> value: game.trailer,
onChange: (e) => {
setTrailer(e.target.value);
},
})}
defaultValue={game.trailer}
/>
</div>
)}
<div
className="relative w-full flex items-center justify-around pt-4"
{...(editable ? getTorrentDropRootProps() : {})}
>
<div className="flex flex-col items-center">
<Link
href={
process.env.NEXT_PUBLIC_CONTENT_URL +
"/" +
watchedData.torrent_file
}
className={clsx(
"p-4 bg-ac0 text-fg1 text-2xl rounded-lg",
!watchedData.torrent_file && "bg-bg1 text-fg4"
)}
>
Скачать {watchedData.title}
</Link>
{editable && (
<>
<input {...getTorrentDropInputProps()} />
<span className="flex flex-col items-center w-full p-1 text-fg4 text-xs lp:text-sm cursor-pointer">
{errors.torrent_file && (
<span className="w-full text-center text-err">
{errors.torrent_file.message}
</span>
)}
{isTorrentDragActive ? (
<span className="w-full text-center">
Изменить .torrent файл...
</span>
) : (
<span className="w-full text-center">
Нажмите, чтобы изменить <br />
.torrent файл
</span>
)}
</span>
</>
)}
</div>
</div> </div>
<div className="w-full flex justify-end"> <div className="w-full flex justify-end">
<Link <Link

View File

@@ -9,62 +9,62 @@ import clsx from "clsx";
import { UserActivities } from "@/features/userActivities"; 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 z-10 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 lp: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 />
<UserActivities /> <UserActivities />
</span> </span>
<label className="flex flex-col items-start relative w-36"> <label className="flex flex-col items-start relative w-36">
<input <input
type="search" type="search"
className="peer/search w-full rounded-lg bg-bg4 px-2" className="peer/search w-full rounded-lg bg-bg4 px-2"
placeholder=" " placeholder=" "
/> />
<span <span
className="peer-focus/search:opacity-0 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

@@ -5,46 +5,46 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
export const MobileMenu = ({ export const MobileMenu = ({
sections, sections,
}: { }: {
sections: { title: string; href: string }[]; sections: { title: string; href: string }[];
}) => { }) => {
const [open, changeMenuOpen] = useState<boolean>(false); const [open, changeMenuOpen] = useState<boolean>(false);
return ( return (
<div className="relative"> <div className="relative">
<button <button
className="w-16 h-16 *:w-12 *:h-1 *:bg-fg1 *:my-3 className="w-16 h-16 *:w-12 *:h-1 *:bg-fg1 *:my-3
*:transition-all *:duration-300 *:relative" *:transition-all *:duration-300 *:relative"
onClick={() => changeMenuOpen(!open)} onClick={() => changeMenuOpen(!open)}
onBlur={() => changeMenuOpen(false)} onBlur={() => changeMenuOpen(false)}
> >
<div <div
className={clsx(open && "rotate-45 top-4", !open && "top-0")} className={clsx(open && "rotate-45 top-4", !open && "top-0")}
></div> ></div>
<div className={clsx(open && "opacity-0")}></div> <div className={clsx(open && "opacity-0")}></div>
<div <div
className={clsx(open && "-rotate-45 bottom-4", !open && "bottom-0")} className={clsx(open && "-rotate-45 bottom-4", !open && "bottom-0")}
></div> ></div>
</button> </button>
<div <div
className={clsx( className={clsx(
"h-0 absolute transition-all duration-300 overflow-hidden\ "h-0 absolute transition-all duration-300 overflow-hidden\
bg-bg4 rounded-lg px-4 flex flex-col shadow-xl", bg-bg4 rounded-lg px-4 flex flex-col shadow-xl",
open && "h-32" open && "h-32"
)} )}
onClick={() => changeMenuOpen(false)} onClick={() => changeMenuOpen(false)}
> >
{sections.map((section) => ( {sections.map((section) => (
<Link <Link
key={section.title} key={section.title}
className="text-xl py-2 cursor-pointer hover:underline" className="text-xl py-2 cursor-pointer hover:underline"
href={section.href} href={section.href}
> >
{section.title} {section.title}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
); );
}; };

View File

@@ -8,50 +8,50 @@ import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
import { boolean } from "zod"; import { boolean } from "zod";
export const Section = ({ export const Section = ({
name, name,
invite_text, invite_text,
link, link,
children, children,
}: { }: {
name?: string; name?: string;
invite_text?: string; invite_text?: string;
link?: string; link?: string;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const router = useRouter(); const router = useRouter();
const [loaded, setLoaded] = useState<boolean>(false); const [loaded, setLoaded] = useState<boolean>(false);
useEffect(() => setLoaded(true), []); 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">
{name && ( {name && (
<h2 <h2
className="text-4xl pb-2 cursor-pointer w-fit" className="text-4xl pb-2 cursor-pointer w-fit"
onClick={() => link && router.push(link)} onClick={() => link && router.push(link)}
> >
{name} {name}
</h2> </h2>
)} )}
<ResponsiveMasonry <ResponsiveMasonry
className={clsx( className={clsx(
"transition-opacity duration-300 opacity-0", "transition-opacity duration-300 opacity-0",
loaded && "opacity-100" loaded && "opacity-100"
)} )}
columnsCountBreakPoints={{ 0: 1, 640: 2, 1024: 3 }} 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 && (
<div className="w-full flex justify-end pt-5"> <div className="w-full flex justify-end pt-5">
<Link <Link
href={link} href={link}
className="text-lg hover:underline underline-offset-4" className="text-lg hover:underline underline-offset-4"
> >
{invite_text} {invite_text}
</Link> </Link>
</div> </div>
)} )}
</section> </section>
); );
}; };

View File

@@ -1,52 +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)", err: "var(--color-err)",
}, },
animation: { animation: {
fadeIn: "fadeIn 0.25s ease-in-out", fadeIn: "fadeIn 0.25s ease-in-out",
fadeOut: "fadeOut 0.25s ease-in-out", fadeOut: "fadeOut 0.25s ease-in-out",
}, },
keyframes: () => ({ keyframes: () => ({
fadeIn: { fadeIn: {
"0%": { opacity: "0" }, "0%": { opacity: "0" },
"100%": { opacity: "1" }, "100%": { opacity: "1" },
}, },
fadeOut: { fadeOut: {
"0%": { opacity: "1" }, "0%": { opacity: "1" },
"100%": { opacity: "0" }, "100%": { opacity: "0" },
}, },
}), }),
}, },
screens: { screens: {
tb: "640px", tb: "640px",
lp: "1024px", lp: "1024px",
dsk: "1280px", dsk: "1280px",
}, },
}, },
plugins: [], plugins: [],
}; };
export default config; export default config;