Work on game form

This commit is contained in:
2024-05-26 20:39:38 +04:00
parent 8b6246a38c
commit b7e137d798
17 changed files with 639 additions and 436 deletions

41
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.51.4", "react-hook-form": "^7.51.4",
"react-responsive-masonry": "^2.2.0", "react-responsive-masonry": "^2.2.0",
"swr": "^2.2.5", "swr": "^2.2.5",
@@ -923,6 +924,14 @@
"integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
"dev": true "dev": true
}, },
"node_modules/attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
"integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==",
"engines": {
"node": ">=4"
}
},
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2026,6 +2035,17 @@
"node": "^10.12.0 || >=12.0.0" "node": "^10.12.0 || >=12.0.0"
} }
}, },
"node_modules/file-selector": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
"integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"dependencies": {
"tslib": "^2.4.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -3263,7 +3283,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3725,7 +3744,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
@@ -3784,6 +3802,22 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-dropzone": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
"integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
"dependencies": {
"attr-accept": "^2.2.2",
"file-selector": "^0.6.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.51.4", "version": "7.51.4",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz",
@@ -3802,8 +3836,7 @@
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
"dev": true
}, },
"node_modules/react-responsive-masonry": { "node_modules/react-responsive-masonry": {
"version": "2.2.0", "version": "2.2.0",

View File

@@ -18,6 +18,7 @@
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.51.4", "react-hook-form": "^7.51.4",
"react-responsive-masonry": "^2.2.0", "react-responsive-masonry": "^2.2.0",
"swr": "^2.2.5", "swr": "^2.2.5",

View File

@@ -0,0 +1,16 @@
import { HTTPService } from "@/shared/utils/http";
import { coverNameSchema } from "./schemas/cover";
export abstract class FilesService {
public static async UploadCover(cover: File) {
const formData = new FormData();
formData.append("cover", cover);
return await HTTPService.post(
`/files/cover`,
coverNameSchema,
formData,
{},
false
);
}
}

View File

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

View File

@@ -0,0 +1,4 @@
import { z } from "zod";
export const coverNameSchema = z.string().min(5);
export type CoverNameType = z.infer<typeof coverNameSchema>;

View File

@@ -1,56 +1,56 @@
import { z } from "zod"; import { z } from "zod";
import { gameCardBaseSchema } from "./gameCard"; import { gameCardBaseSchema } from "./gameCard";
export const gameBaseSchema = gameCardBaseSchema.and( export const gameBaseSchema = gameCardBaseSchema.merge(
z.object({ z.object({
torrent_file: z.string().min(1), torrent_file: z.string().min(1),
trailer: z.string().optional(), trailer: z.string().optional(),
system: z.string().optional(), system: z.string().optional(),
processor: z.string().optional(), processor: z.string().optional(),
memory: z.string().optional(), memory: z.string().optional(),
graphics: z.string().optional(), graphics: z.string().optional(),
storage: z.string().optional(), storage: z.string().optional(),
developer: z.string().optional(), developer: z.string().optional(),
language: z.string().optional(), language: z.string().optional(),
download_size: z.string().optional(), download_size: z.string().optional(),
release_date: z release_date: z
.string() .string()
.min(1) .min(1)
.transform((d) => new Date(d)), .transform((d) => new Date(d)),
}) })
); );
export const gameCreateSchema = gameBaseSchema.and(z.object({})); export const gameCreateSchema = gameBaseSchema.merge(z.object({}));
export type GameCreateType = z.infer<typeof gameCreateSchema>; export type GameCreateType = z.infer<typeof gameCreateSchema>;
export const gameSchema = gameBaseSchema.and( export const gameSchema = gameBaseSchema.merge(
z.object({ z.object({
id: z.number().positive(), id: z.number().positive(),
owner_id: z.number().positive(), owner_id: z.number().positive(),
update_date: z update_date: z
.string() .string()
.min(1) .min(1)
.transform((d) => new Date(d)), .transform((d) => new Date(d)),
upload_date: z upload_date: z
.string() .string()
.min(1) .min(1)
.transform((d) => new Date(d)), .transform((d) => new Date(d)),
}) })
); );
export type GameType = z.infer<typeof gameSchema>; export type GameType = z.infer<typeof gameSchema>;
export const isGame = (a: any): a is GameType => { export const isGame = (a: any): a is GameType => {
return gameSchema.safeParse(a).success; return gameSchema.safeParse(a).success;
}; };
export const gamesSchema = z.array(z.any()).transform((a) => { export const gamesSchema = z.array(z.any()).transform((a) => {
const games: GameType[] = []; const games: GameType[] = [];
a.forEach((e) => { a.forEach((e) => {
if (isGame(e)) games.push(gameSchema.parse(e)); if (isGame(e)) games.push(gameSchema.parse(e));
else console.error("Game parse error - ", e); else console.error("Game parse error - ", e);
}); });
return games; return games;
}); });

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,32 @@
import { GameCardType } from "@/entities/game"; import { GameCardType } from "@/entities/game";
import Image from "next/image"; import { Img } from "@/shared/ui";
import Link from "next/link"; import Link from "next/link";
export const GameCard = ({ card }: { card: GameCardType }) => { export const GameCard = ({ card }: { card: GameCardType }) => {
return ( return (
<Link className="group/gamecard cursor-pointer" href={"/games/" + card.id}> <Link className="group/gamecard cursor-pointer" href={"/games/" + card.id}>
{!!card.cover_preview && ( {!!card.cover && (
<Image <Img
src={card.cover_preview} src={card.cover}
className="rounded-lg object-contain" preview={true}
alt="" className="rounded-lg object-contain"
width={1280} width={1280}
height={720} height={720}
/> />
)} )}
<div className="flex items-center justify-between pr-2"> <div className="flex items-center justify-between pr-2">
<h2 className="text-3xl tb:text-xl py-1 group-hover/gamecard:underline underline-offset-1"> <h2 className="text-3xl tb:text-xl py-1 group-hover/gamecard:underline underline-offset-1">
{card.title} {card.title}
</h2> </h2>
{card.version && ( {card.version && (
<span className="text-xs max-w-[30%] text-right line-clamp-2 text-fg4"> <span className="text-xs max-w-[30%] text-right line-clamp-2 text-fg4">
{card.version} {card.version}
</span> </span>
)} )}
</div> </div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4"> <p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{card.description} {card.description}
</p> </p>
</Link> </Link>
); );
}; };

View File

@@ -6,84 +6,85 @@ import Link from "next/link";
import useSWR from "swr"; import useSWR from "swr";
import clsx from "clsx"; import clsx from "clsx";
import { useState } from "react"; import { useState } from "react";
import { TokenData } from "@/entities/user/schemas/auth";
export const UserActivities = () => { export const UserActivities = () => {
const { data: me } = useSWR("user", () => UserService.IdentifyYourself()); const { data: me } = useSWR("user", () => UserService.IdentifyYourself());
const [open, changeMenuOpen] = useState<boolean>(false); const [open, changeMenuOpen] = useState<boolean>(false);
return ( return (
<> <>
<span className="group/login cursor-pointer flex items-center"> <span className="group/login cursor-pointer flex items-center">
<PersonIcon className="mr-1 h-4 w-4" /> <PersonIcon className="mr-1 h-4 w-4" />
{me && ( {me && (
<div className="relative"> <div className="relative">
<button <button
className="group-hover/login:underline" className="group-hover/login:underline"
onClick={() => changeMenuOpen(!open)} onClick={() => changeMenuOpen(!open)}
onBlur={() => changeMenuOpen(false)} onBlur={() => changeMenuOpen(false)}
> >
{me.name} {me.username}
</button> </button>
<li <li
className={clsx( className={clsx(
"h-0 absolute transition-all duration-300", "h-0 absolute transition-all duration-300",
"overflow-hidden bg-bg4 rounded-lg pl-2", "overflow-hidden bg-bg4 rounded-lg pl-2",
"flex flex-col z-10 shadow-3xl w-60", "flex flex-col z-10 shadow-3xl w-60",
"right-0 top-8", "right-0 top-8",
open && "h-40 py-2" open && "h-40 py-2"
)} )}
onClick={() => changeMenuOpen(false)} onClick={() => changeMenuOpen(false)}
> >
{[ {[
{ {
group: "Добавить:", group: "Добавить:",
items: [ items: [
{ name: "Добавить игру", link: "/games/add" }, { name: "Добавить игру", link: "/games/add" },
{ name: "Добавить фильм", link: "/films/add" }, { name: "Добавить фильм", link: "/films/add" },
{ name: "Добавить аудиокнигу", link: "/audiobooks/add" }, { name: "Добавить аудиокнигу", link: "/audiobooks/add" },
], ],
}, },
{ name: "Выйти", link: "/logout" }, { name: "Выйти", link: "/logout" },
].map((item) => ( ].map((item) => (
<ul key={item.group ?? item.name}> <ul key={item.group ?? item.name}>
{item.group && ( {item.group && (
<> <>
<div className="text-xl font-bold">{item.group}</div> <div className="text-xl font-bold">{item.group}</div>
<li className="pl-4 pb-0"> <li className="pl-4 pb-0">
{item.items.map((item) => ( {item.items.map((item) => (
<ul key={item.name}> <ul key={item.name}>
<Link <Link
key={item.name} key={item.name}
className="text-lg py-2 cursor-pointer hover:underline" className="text-lg py-2 cursor-pointer hover:underline"
href={item.link} href={item.link}
> >
{item.name} {item.name}
</Link> </Link>
</ul> </ul>
))} ))}
</li> </li>
</> </>
)} )}
{!item.group && item.link && ( {!item.group && item.link && (
<Link <Link
key={item.name} key={item.name}
className="text-xl font-bold py-2 cursor-pointer hover:underline" className="text-xl font-bold py-2 cursor-pointer hover:underline"
href={item.link} href={item.link}
> >
{item.name} {item.name}
</Link> </Link>
)} )}
</ul> </ul>
))} ))}
</li> </li>
</div> </div>
)} )}
{!me && ( {!me && (
<Link href="/login" className="cursor-pointer flex items-center"> <Link href="/login" className="cursor-pointer flex items-center">
<span className="group-hover/login:underline">Войти</span> <span className="group-hover/login:underline">Войти</span>
</Link> </Link>
)} )}
</span> </span>
</> </>
); );
}; };

View File

@@ -1,5 +1,6 @@
import { SearchIcon } from "./searchIcon"; import { SearchIcon } from "./searchIcon";
import { PersonIcon } from "./personIcon"; import { PersonIcon } from "./personIcon";
import { SunIcon } from "./sunIcon"; import { SunIcon } from "./sunIcon";
import { SpinnerIcon } from "./spinnerIcon";
export { SearchIcon, PersonIcon, SunIcon }; export { SearchIcon, PersonIcon, SunIcon, SpinnerIcon };

View File

@@ -0,0 +1,28 @@
export const SpinnerIcon = ({ className }: { className?: string }) => {
return (
<svg
className={"w-4 h-4 text-fg4 animate-spin " + className}
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
>
<path
d="M32 3C35.8083 3 39.5794 3.75011 43.0978 5.20749C46.6163 6.66488 49.8132 8.80101 52.5061 11.4939C55.199 14.1868 57.3351 17.3837 58.7925 20.9022C60.2499 24.4206 61 28.1917 61 32C61 35.8083 60.2499 39.5794 58.7925 43.0978C57.3351 46.6163 55.199 49.8132 52.5061 52.5061C49.8132 55.199 46.6163 57.3351 43.0978 58.7925C39.5794 60.2499 35.8083 61 32 61C28.1917 61 24.4206 60.2499 20.9022 58.7925C17.3837 57.3351 14.1868 55.199 11.4939 52.5061C8.801 49.8132 6.66487 46.6163 5.20749 43.0978C3.7501 39.5794 3 35.8083 3 32C3 28.1917 3.75011 24.4206 5.2075 20.9022C6.66489 17.3837 8.80101 14.1868 11.4939 11.4939C14.1868 8.80099 17.3838 6.66487 20.9022 5.20749C24.4206 3.7501 28.1917 3 32 3L32 3Z"
stroke="currentColor"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M32 3C36.5778 3 41.0906 4.08374 45.1692 6.16256C49.2477 8.24138 52.7762 11.2562 55.466 14.9605C58.1558 18.6647 59.9304 22.9531 60.6448 27.4748C61.3591 31.9965 60.9928 36.6232 59.5759 40.9762"
stroke="currentColor"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-bg1"
></path>
</svg>
);
};

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

@@ -0,0 +1,33 @@
import Image from "next/image";
export const Img = ({
src,
preview = false,
width,
height,
alt,
className,
}: {
src: string;
preview?: boolean;
width?: number;
height?: number;
alt?: string;
className: string;
}) => {
return (
<Image
className={className}
src={
(preview
? process.env.NEXT_PUBLIC_COVER_PREVIEW_URL
: process.env.NEXT_PUBLIC_COVER_FULL_URL) +
"/" +
src
}
width={width}
height={height}
alt={alt ?? ""}
/>
);
};

View File

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

View File

@@ -4,57 +4,61 @@ import { z } from "zod";
type Body = BodyInit | object; type Body = BodyInit | object;
export abstract class HTTPService { export abstract class HTTPService {
public static async request<Z extends z.ZodTypeAny>( public static async request<Z extends z.ZodTypeAny>(
method: "GET" | "POST" | "PUT" | "DELETE", method: "GET" | "POST" | "PUT" | "DELETE",
url: string, url: string,
schema: Z, schema: Z,
body?: Body, body?: Body,
headers?: HeadersInit, headers?: HeadersInit,
stringify?: boolean stringify?: boolean
) { ) {
return await fetch(process.env.NEXT_PUBLIC_BASE_URL + url, { return await fetch(process.env.NEXT_PUBLIC_BASE_URL + url, {
method: method, method: method,
headers: { headers: {
accept: "application/json", accept: "application/json",
Authorization: "Bearer " + UserService.GetToken(), ...((stringify ?? true) != true
...headers, ? {}
}, : { "Content-Type": "application/json" }),
body: stringify ? JSON.stringify(body) : (body as BodyInit), Authorization: "Bearer " + UserService.GetToken(),
cache: "no-cache", ...headers,
}) },
.then((r) => { body:
if (r && r.ok) return r; (stringify ?? true) != true ? (body as BodyInit) : JSON.stringify(body),
else throw Error("Response ok = false"); cache: "no-cache",
}) })
.then((r) => r.json()) .then((r) => {
.then((d) => schema.parse(d) as z.infer<Z>) if (r && r.ok) return r;
.catch((e) => { else throw Error("Response ok = false");
console.error(e); })
return null; .then((r) => r.json())
}); .then((d) => schema.parse(d) as z.infer<Z>)
} .catch((e) => {
console.error(e);
return null;
});
}
public static async get<Z extends z.ZodTypeAny>(url: string, schema: Z) { public static async get<Z extends z.ZodTypeAny>(url: string, schema: Z) {
return await this.request<Z>("GET", url, schema); return await this.request<Z>("GET", url, schema);
} }
public static async post<Z extends z.ZodTypeAny>( public static async post<Z extends z.ZodTypeAny>(
url: string, url: string,
schema: Z, schema: Z,
body?: Body, body?: Body,
headers?: HeadersInit, headers?: HeadersInit,
stringify?: boolean stringify?: boolean
) { ) {
return await this.request<Z>("POST", url, schema, body, headers, stringify); return await this.request<Z>("POST", url, schema, body, headers, stringify);
} }
public static async put<Z extends z.ZodType>( public static async put<Z extends z.ZodType>(
url: string, url: string,
schema: Z, schema: Z,
body?: Body, body?: Body,
headers?: HeadersInit, headers?: HeadersInit,
stringify?: boolean stringify?: boolean
) { ) {
return await this.request<Z>("PUT", url, schema, body, headers, stringify); return await this.request<Z>("PUT", url, schema, body, headers, stringify);
} }
} }

View File

@@ -1,184 +1,273 @@
"use client"; "use client";
import { import {
gameCreateSchema, gameCreateSchema,
GameCreateType, GameCreateType,
GameService, GameService,
GameType, GameType,
} from "@/entities/game"; } from "@/entities/game";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image";
import Link from "next/link"; 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 { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { SubmitHandler, 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 { useDropzone } from "react-dropzone";
import { FilesService } from "@/entities/files";
import { SpinnerIcon } from "@/shared/assets/icons";
const propertyUnknownText = "Не известно";
export const GameInfo = ({ game }: { game: GameType }) => { 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(() => setEditable(me?.id === game.owner_id), [me, game]);
const { const {
register, register,
handleSubmit, handleSubmit,
setValue, setValue,
formState: { errors }, watch,
} = useForm<GameCreateType>({ formState: { dirtyFields, errors },
resolver: zodResolver(gameCreateSchema), } = useForm<GameCreateType>({
}); 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 });
}, []); }, []);
useEffect(() => { const [savedTimeout, changeSavedTimeout] = useState<NodeJS.Timeout | null>(
console.log(errors); null
}, [errors]); );
const watchedData = watch();
const [formData, changeFormData] = useState<GameCreateType | null>(null);
useEffect(() => {
if (!Object.keys(dirtyFields).length) return;
if (JSON.stringify(watchedData) === JSON.stringify(formData)) return;
console.log(dirtyFields);
changeFormData(watchedData);
if (savedTimeout) clearTimeout(savedTimeout);
changeSavedTimeout(
setTimeout(() => {
if (formRef.current) formRef.current.requestSubmit();
}, 5000)
);
}, [watchedData]);
const onSubmit = (formData: GameCreateType) => { const onSubmit = async (formData: GameCreateType) => {
const updatedGame = GameService.changeGame(game.id, formData); changeSavedTimeout(null);
console.log(updatedGame); const updatedGame = await GameService.changeGame(game.id, formData);
}; console.log(updatedGame);
};
return ( const [cover, setCover] = useState<string | undefined>(game.cover);
<form
onSubmit={handleSubmit(onSubmit)} const onDrop = useCallback((acceptedFiles: File[]) => {
className="p-4 flex flex-col lp:block" const file = acceptedFiles[0];
> const fileReader = new FileReader();
{game.cover && ( fileReader.onload = async () => {
<div className="lp:w-[60%] lp:px-4 lp:pl-0 py-2 float-left"> const coverName = await FilesService.UploadCover(file);
<Image if (coverName) {
src={game.cover} setCover(coverName);
className="rounded-lg w-full object-contain" setValue("cover", coverName);
alt="" }
width={1280} };
height={720} fileReader.readAsDataURL(file);
/> }, []);
</div>
)} const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
<span className="lp:max-w-[40%]">
<h1 const formRef = useRef<HTMLFormElement>(null);
className={clsx(
"text-4xl outline-none", return (
!editable && "cursor-default" <form
)} onSubmit={handleSubmit(onSubmit)}
suppressContentEditableWarning={true} className="p-4 flex flex-col lp:block"
contentEditable={editable} ref={formRef}
{...register("title", { value: game.title })} >
onInput={(e) => { {cover && (
setValue("title", e.currentTarget.innerText, { <div
shouldValidate: true, className="lp:w-[60%] lp:px-4 lp:pl-0 pt-2 float-left"
}); {...(editable ? getRootProps() : {})}
console.log(); >
}} <Img
> src={cover}
{game.title} preview={false}
</h1> className="transition-all rounded-lg w-full object-contain"
{game.description && ( width={1280}
<div height={720}
contentEditable={editable} />
suppressContentEditableWarning={true} {editable && (
className={clsx( <>
"text-md text-justify", <input {...getInputProps()} />
"text-fg4 pt-2 outline-none", <span className="flex items-center ju w-full p-1">
!editable && "cursor-default" {isDragActive ? (
)} <p>Изменить обложку...</p>
{...register("description", { value: game.description })} ) : (
onInput={(e) => { <span className="text-sm w-full flex justify-around">
setValue("description", e.currentTarget.innerText, { Для редактирования нажмите или перетащите новую обложку
shouldValidate: true, поверх старой
}); </span>
}} )}
> </span>
{game.description} </>
</div> )}
)} </div>
</span> )}
<div <span className="lp:max-w-[40%]">
className={clsx( <span className="flex items-end justify-between">
"w-full flex justify-between pt-4", <h1
!editable && "cursor-default" className={clsx(
)} "text-4xl outline-none max-w-[80%]",
> !editable && "cursor-default"
{[ )}
[ suppressContentEditableWarning={true}
{ name: "Система", key: "system" }, contentEditable={editable}
{ name: "Процессор", key: "processor" }, {...register("title", { value: game.title })}
{ name: "Оперативная память", key: "memory" }, onInput={(e) => {
{ name: "Видеокарта", key: "graphics" }, setValue("title", e.currentTarget.innerText, {
{ name: "Место на диске", key: "storage" }, shouldValidate: true,
], shouldDirty: true,
[ });
{ name: "Версия игры", key: "version" }, console.log();
{ }}
name: "Дата обновления раздачи", >
key: "update_date", {game.title}
value: game.update_date.toLocaleDateString("ru-ru"), </h1>
}, <span className="text-sm text-fg4 flex items-center">
{ name: "Язык", key: "language" }, {savedTimeout && (
{ name: "Разработчик", key: "developer" }, <>
{ <SpinnerIcon className="mr-2" />
name: "Год выхода", Редактируется
key: "release_date", </>
value: game.release_date.toLocaleDateString("en-us", { )}
year: "numeric", {!savedTimeout && "Сохранено"}
}), </span>
}, </span>
{ name: "Объём загрузки", key: "download_size" },
], {game.description && (
].map((section, i) => ( <div
<ul key={i} className="w-[48%] bg-bg1 rounded-lg py-1 px-4"> contentEditable={editable}
{section.map((req) => ( suppressContentEditableWarning={true}
<li key={req.name} className="font-bold text-sm lp:text-md py-1"> className={clsx(
{req.name + ": "} "text-md text-justify",
<input "text-fg4 pt-2 outline-none",
readOnly={!editable} !editable && "cursor-default"
className={clsx( )}
"font-normal outline-none bg-bg1", {...register("description", { value: game.description })}
req.value === undefined && "text-fg4" onInput={(e) => {
)} setValue("description", e.currentTarget.innerText, {
{...register(req.key as keyof GameCreateType)} shouldValidate: true,
defaultValue={ shouldDirty: true,
req.value ?? });
(game[req.key as keyof GameType] as string) ?? }}
"Не известно" >
} {game.description}
></input> </div>
</li> )}
))} </span>
</ul> <div
))} className={clsx(
</div> "w-full flex justify-between pt-4",
{game.trailer && getYouTubeID(game.trailer) && ( !editable && "cursor-default"
<iframe )}
src={"https://youtube.com/embed/" + getYouTubeID(game.trailer)} >
className="w-full aspect-video rounded-lg mt-4" {(
allowFullScreen [
/> [
)} { name: "Система", key: "system" },
<div className="relative w-full flex items-center justify-around pt-4"> { name: "Процессор", key: "processor" },
<Link { name: "Оперативная память", key: "memory" },
href={process.env.NEXT_PUBLIC_CONTENT_URL + "/" + game.torrent_file} { name: "Видеокарта", key: "graphics" },
className="p-4 bg-ac0 text-fg1 text-2xl rounded-lg" { name: "Место на диске", key: "storage" },
> ],
Скачать {game.title} [
</Link> { name: "Версия игры", key: "version" },
</div> {
<div className="w-full flex justify-end"> name: "Дата обновления раздачи",
<Link key: "update_date",
className="text-right text-sm relative top-4 lp:-top-4" value: game.update_date.toLocaleDateString("ru-ru"),
href="/how_to_download" },
> { name: "Язык", key: "language" },
Как скачать игру { name: "Разработчик", key: "developer" },
<br /> с помощью .torrent файла? {
</Link> name: "Год выхода",
</div> key: "release_date",
<input type="submit" className=" m-2 p-2 bg-ac0" /> value: game.release_date.toLocaleDateString("en-us", {
</form> year: "numeric",
); }),
},
{ name: "Объём загрузки", key: "download_size" },
],
] as { name: string; key: keyof GameCreateType; value?: string }[][]
).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>
))}
</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
/>
)}
<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>
</div>
<div className="w-full flex justify-end">
<Link
className="text-right text-sm relative top-4 lp:-top-4"
href="/how_to_download"
>
Как скачать игру
<br /> с помощью .torrent файла?
</Link>
</div>
<input type="submit" className="hidden" />
</form>
);
}; };