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

@@ -8,8 +8,8 @@ export default async function Games({
}: {
params: { game_id: number };
}) {
const gameCards = await GameService.getGameCards();
const game = await GameService.getGame(game_id);
const gameCards = await GameService.GetGameCards();
const game = await GameService.GetGame(game_id);
return (
<>
{game && <GameInfo game={game} />}

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

@@ -24,9 +24,9 @@ export default async function HowToDownload() {
uTorrent, BitTorrent или qBittorrent.
</li>
<li>
3. В программе-клиенте выберите опцию "Open Torrent File" или "Add
Torrent" и выберите торрент-файл, который вы скачали в первом
шаге.
3. В программе-клиенте выберите опцию &quot;Open Torrent
File&quot; или &quot;Add Torrent&quot; и выберите торрент-файл,
который вы скачали в первом шаге.
</li>
<li>
4. После этого начнется загрузка файлов, указанных в

View File

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

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() {
public static async GetGameCards() {
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);
}
public static async changeGame(id: number, gameInfo: GameCreateType) {
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,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

@@ -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 onDrop = useCallback((acceptedFiles: File[]) => {
const onCoverDrop = 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);
setValue("cover", coverName, {
shouldValidate: true,
shouldDirty: true,
});
}
};
fileReader.readAsDataURL(file);
}, []);
},
[setValue]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
const {
getRootProps: getCoverDropRootProps,
getInputProps: getCoverDropInputProps,
isDragActive: isCoverDragActive,
} = useDropzone({ onDrop: onCoverDrop });
const formRef = useRef<HTMLFormElement>(null);
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]
);
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() : {})}
>
{(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={cover}
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 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>
)}
<span className="lp:max-w-[40%]">
<span className="flex items-end justify-between">
</div>
)}
<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,23 +217,42 @@ 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 && (
{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>
{game.description && (
<div
contentEditable={editable}
suppressContentEditableWarning={true}
@@ -170,6 +271,7 @@ export const GameInfo = ({ game }: { game: GameType }) => {
>
{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}
<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(
"font-normal outline-none bg-bg1",
req.value === undefined &&
(game[req.key] === undefined ||
game[req.key] === propertyUnknownText) &&
"text-fg4",
!editable && "cursor-default"
"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) ??
propertyUnknownText,
value: req.value ?? (game[req.key] as string),
})}
defaultValue={
req.value ??
(game[req.key] as string) ??
propertyUnknownText
}
onBlur={(e) => {
if (e.target.value === "") {
e.target.value = propertyUnknownText;
}
contentEditable={editable && (req.edit ?? true)}
suppressContentEditableWarning={true}
onInput={(e) => {
setValue(req.key, e.currentTarget.innerText, {
shouldValidate: true,
shouldDirty: true,
});
}}
></input>
>
{req.value ?? (game[req.key] as string)}
</span>
</span>
</li>
))}
</ul>
))}
</div>
{game.trailer && getYouTubeID(game.trailer) && (
{(trailer || editable) && (
<div className="w-ful aspect-video">
{trailer && getYouTubeID(trailer) && (
<iframe
src={"https://youtube.com/embed/" + getYouTubeID(game.trailer)}
src={"https://youtube.com/embed/" + getYouTubeID(trailer)}
className="w-full aspect-video rounded-lg mt-4"
allowFullScreen
/>
)}
<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"
{!trailer && editable && (
<div className="mt-4 w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
)}
</div>
)}
{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() : {})}
>
Скачать {game.title}
<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