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";
import {
LoginForm,
loginFormFieldNames,
loginFormSchema,
LoginForm,
loginFormFieldNames,
loginFormSchema,
} from "@/entities/user";
import { UserService } from "@/entities/user/user";
import { Modal } from "@/shared/ui";
@@ -13,60 +13,60 @@ import { SubmitHandler, useForm } from "react-hook-form";
import { mutate } from "swr";
export default function Login() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({ resolver: zodResolver(loginFormSchema) });
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({ resolver: zodResolver(loginFormSchema) });
const router = useRouter();
const router = useRouter();
const onSubmit: SubmitHandler<LoginForm> = async (data) => {
const userInfo = await UserService.Login(data);
mutate("user", userInfo);
router.back();
};
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 (keyof LoginForm)[]).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
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 (keyof LoginForm)[]).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>
))}
>
{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>
);
<input
type="submit"
value="Войти"
className="bg-ac0 mt-2 p-1 px-4 rounded-lg"
/>
</form>
</div>
</Modal>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,4 +13,16 @@ export abstract class FilesService {
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 { gameCardsSchema, GameCardType } from "./schemas/gameCard";
import { gameCardsSchema } from "./schemas/gameCard";
import { GameCreateType, gameSchema } from "./schemas/game";
import { z } from "zod";
export abstract class GameService {
public static async getGameCards() {
return await HTTPService.get("/games/cards", gameCardsSchema);
}
public static async getGame(id: number) {
return await HTTPService.get(`/games/${id}`, gameSchema);
}
public static async changeGame(id: number, gameInfo: GameCreateType) {
return await HTTPService.put(`/games/${id}`, gameSchema, gameInfo);
}
public static async GetGameCards() {
return await HTTPService.get("/games/cards", gameCardsSchema);
}
public static async GetGame(id: number) {
return await HTTPService.get(`/games/${id}`, gameSchema);
}
public static async ChangeGame(id: number, gameInfo: GameCreateType) {
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";
export {
GameService,
gameCreateSchema,
type GameType,
type GameCreateType,
type GameCardType,
GameService,
gameCreateSchema,
type GameType,
type GameCreateType,
type GameCardType,
};

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
import {
loginFormSchema,
loginFormFieldNames,
LoginForm,
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,
loginFormSchema,
loginFormFieldNames,
UserService,
userSchema,
type User,
type LoginForm,
};

View File

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

View File

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

View File

@@ -5,29 +5,29 @@ 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>
);
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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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