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 }; 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} />}

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

@@ -24,9 +24,9 @@ export default async function HowToDownload() {
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. После этого начнется загрузка файлов, указанных в

View File

@@ -10,7 +10,7 @@ export const metadata: Metadata = {
}; };
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 && (

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,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

@@ -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 onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0]; const file = acceptedFiles[0];
const fileReader = new FileReader(); const fileReader = new FileReader();
fileReader.onload = async () => { fileReader.onload = async () => {
const coverName = await FilesService.UploadCover(file); const coverName = await FilesService.UploadCover(file);
if (coverName) { if (coverName) {
setCover(coverName); setValue("cover", coverName, {
setValue("cover", coverName); shouldValidate: true,
shouldDirty: true,
});
} }
}; };
fileReader.readAsDataURL(file); 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 ( 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 <Img
src={cover} src={watchedData.cover}
preview={false} preview={false}
className="transition-all rounded-lg w-full object-contain" className="transition-all rounded-lg w-full object-contain"
width={1280} width={1280}
height={720} 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>
)} )}
<span className="lp:max-w-[40%]"> </div>
<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 <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,23 +217,42 @@ 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 && ( {editable && (
<span className="text-sm text-fg4 flex items-center cursor-default">
{savedTimeout && Object.keys(errors).length === 0 && (
<> <>
<SpinnerIcon className="mr-2" /> <SpinnerIcon className="mr-2" />
Редактируется Редактируется
</> </>
)} )}
{savedTimeout && Object.keys(errors).length > 0 && (
<span className="text-err text-right">Некорректные данные</span>
)}
{!savedTimeout && "Сохранено"} {!savedTimeout && "Сохранено"}
</span> </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> </span>
{game.description && (
<div <div
contentEditable={editable} contentEditable={editable}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
@@ -170,6 +271,7 @@ export const GameInfo = ({ game }: { game: GameType }) => {
> >
{game.description} {game.description}
</div> </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 "
)}
>
Не известно
</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, { {...register(req.key, {
value: value: req.value ?? (game[req.key] as string),
req.value ??
(game[req.key] as string) ??
propertyUnknownText,
})} })}
defaultValue={ contentEditable={editable && (req.edit ?? true)}
req.value ?? suppressContentEditableWarning={true}
(game[req.key] as string) ?? onInput={(e) => {
propertyUnknownText setValue(req.key, e.currentTarget.innerText, {
} shouldValidate: true,
onBlur={(e) => { shouldDirty: true,
if (e.target.value === "") { });
e.target.value = propertyUnknownText;
}
}} }}
></input> >
{req.value ?? (game[req.key] as string)}
</span>
</span>
</li> </li>
))} ))}
</ul> </ul>
))} ))}
</div> </div>
{game.trailer && getYouTubeID(game.trailer) && ( {(trailer || editable) && (
<div className="w-ful aspect-video">
{trailer && getYouTubeID(trailer) && (
<iframe <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" className="w-full aspect-video rounded-lg mt-4"
allowFullScreen allowFullScreen
/> />
)} )}
<div className="relative w-full flex items-center justify-around pt-4"> {!trailer && editable && (
<Link <div className="mt-4 w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
href={process.env.NEXT_PUBLIC_CONTENT_URL + "/" + game.torrent_file} )}
className="p-4 bg-ac0 text-fg1 text-2xl rounded-lg" </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> </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