diff --git a/package-lock.json b/package-lock.json index 7cea639..b1de118 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.51.4", "react-responsive-masonry": "^2.2.0", "swr": "^2.2.5", @@ -923,6 +924,14 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "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": { "version": "1.0.7", "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_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": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -3263,7 +3283,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3725,7 +3744,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -3784,6 +3802,22 @@ "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": { "version": "7.51.4", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz", @@ -3802,8 +3836,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-responsive-masonry": { "version": "2.2.0", diff --git a/package.json b/package.json index baed7fb..6e4e21e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.51.4", "react-responsive-masonry": "^2.2.0", "swr": "^2.2.5", diff --git a/src/entities/files/files.ts b/src/entities/files/files.ts new file mode 100644 index 0000000..c08dc4c --- /dev/null +++ b/src/entities/files/files.ts @@ -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 + ); + } +} diff --git a/src/entities/files/index.ts b/src/entities/files/index.ts new file mode 100644 index 0000000..54d81f1 --- /dev/null +++ b/src/entities/files/index.ts @@ -0,0 +1,3 @@ +import { FilesService } from "./files"; + +export { FilesService }; diff --git a/src/entities/files/schemas/cover.ts b/src/entities/files/schemas/cover.ts new file mode 100644 index 0000000..df97e7c --- /dev/null +++ b/src/entities/files/schemas/cover.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; + +export const coverNameSchema = z.string().min(5); +export type CoverNameType = z.infer; diff --git a/src/entities/game/schemas/game.ts b/src/entities/game/schemas/game.ts index 0b3b2ee..5d6ac12 100644 --- a/src/entities/game/schemas/game.ts +++ b/src/entities/game/schemas/game.ts @@ -1,56 +1,56 @@ import { z } from "zod"; import { gameCardBaseSchema } from "./gameCard"; -export const gameBaseSchema = gameCardBaseSchema.and( - z.object({ - torrent_file: z.string().min(1), - trailer: z.string().optional(), +export const gameBaseSchema = gameCardBaseSchema.merge( + z.object({ + torrent_file: z.string().min(1), + 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(), + processor: z.string().optional(), + memory: z.string().optional(), + graphics: z.string().optional(), + storage: z.string().optional(), - developer: z.string().optional(), - language: z.string().optional(), - download_size: z.string().optional(), + developer: z.string().optional(), + language: z.string().optional(), + download_size: z.string().optional(), - release_date: z - .string() - .min(1) - .transform((d) => new Date(d)), - }) + release_date: z + .string() + .min(1) + .transform((d) => new Date(d)), + }) ); -export const gameCreateSchema = gameBaseSchema.and(z.object({})); +export const gameCreateSchema = gameBaseSchema.merge(z.object({})); export type GameCreateType = z.infer; -export const gameSchema = gameBaseSchema.and( - z.object({ - id: z.number().positive(), - owner_id: z.number().positive(), - update_date: z - .string() - .min(1) - .transform((d) => new Date(d)), - upload_date: z - .string() - .min(1) - .transform((d) => new Date(d)), - }) +export const gameSchema = gameBaseSchema.merge( + z.object({ + id: z.number().positive(), + owner_id: z.number().positive(), + update_date: z + .string() + .min(1) + .transform((d) => new Date(d)), + upload_date: z + .string() + .min(1) + .transform((d) => new Date(d)), + }) ); export type GameType = z.infer; 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) => { - const games: GameType[] = []; - a.forEach((e) => { - if (isGame(e)) games.push(gameSchema.parse(e)); - else console.error("Game parse error - ", e); - }); - return games; + const games: GameType[] = []; + a.forEach((e) => { + if (isGame(e)) games.push(gameSchema.parse(e)); + else console.error("Game parse error - ", e); + }); + return games; }); diff --git a/src/entities/game/schemas/gameCard.ts b/src/entities/game/schemas/gameCard.ts index 2378540..0d7ebad 100644 --- a/src/entities/game/schemas/gameCard.ts +++ b/src/entities/game/schemas/gameCard.ts @@ -1,40 +1,28 @@ 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(), - }) - .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 gameCardBaseSchema = z.object({ + title: z.string().min(3), + cover: z.string().optional(), + description: z.string().optional(), + version: z.string().optional(), +}); -export const gameCardSchema = gameCardBaseSchema.and( - z.object({ - id: z.number().positive(), - }) +export const gameCardSchema = gameCardBaseSchema.merge( + z.object({ + id: z.number().positive(), + }) ); export type GameCardType = z.infer; 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) => { - const cards: GameCardType[] = []; - a.forEach((e) => { - if (isGameCard(e)) cards.push(gameCardSchema.parse(e)); - else console.error("GameCard parse error - ", e); - }); - return cards; + const cards: GameCardType[] = []; + a.forEach((e) => { + if (isGameCard(e)) cards.push(gameCardSchema.parse(e)); + else console.error("GameCard parse error - ", e); + }); + return cards; }); diff --git a/src/entities/user/schemas/user.ts b/src/entities/user/schemas/user.ts index ce284e6..c9203e7 100644 --- a/src/entities/user/schemas/user.ts +++ b/src/entities/user/schemas/user.ts @@ -1,8 +1,8 @@ import { z } from "zod"; export const userSchema = z.object({ - id: z.number().positive(), - name: z.string().min(3), - email: z.string().min(3), + id: z.number().positive(), + username: z.string().min(3), + email: z.string().min(3), }); export type User = z.infer; diff --git a/src/entities/user/user.ts b/src/entities/user/user.ts index b7d0875..ba33d33 100644 --- a/src/entities/user/user.ts +++ b/src/entities/user/user.ts @@ -1,52 +1,53 @@ import { HTTPService } from "@/shared/utils/http"; import { - LoginForm, - TokenData, - tokenDataSchema, - TokenResponse, - tokenResponseSchema, + LoginForm, + TokenData, + tokenDataSchema, + TokenResponse, + tokenResponseSchema, } from "./schemas/auth"; import { jwtDecode } from "jwt-decode"; import Cookies from "js-cookie"; export abstract class UserService { - public static async Login(loginForm: LoginForm) { - const accessToken = await HTTPService.post( - "/auth", - tokenResponseSchema, - new URLSearchParams(Object.entries(loginForm)), - { - "Content-Type": "application/x-www-form-urlencoded", - } - ); - if (accessToken) { - const tokenData = this.DecodeToken(accessToken); - if (tokenData) { - Cookies.set("access-token", accessToken, { - secure: true, - expires: tokenData.expire, - }); - return tokenData; - } - } - } + public static async Login(loginForm: LoginForm) { + const accessToken = await HTTPService.post( + "/auth", + tokenResponseSchema, + new URLSearchParams(Object.entries(loginForm)), + { + "Content-Type": "application/x-www-form-urlencoded", + }, + false + ); + if (accessToken) { + const tokenData = this.DecodeToken(accessToken); + if (tokenData) { + Cookies.set("access-token", accessToken, { + secure: true, + expires: tokenData.expire, + }); + return tokenData; + } + } + } - public static GetToken(): string | undefined { - return Cookies.get("access-token"); - } + public static GetToken(): string | undefined { + return Cookies.get("access-token"); + } - public static IdentifyYourself(): TokenData | undefined { - const token = Cookies.get("access-token"); - if (token) { - return this.DecodeToken(token); - } - } + public static IdentifyYourself(): TokenData | undefined { + const token = Cookies.get("access-token"); + if (token) { + return this.DecodeToken(token); + } + } - public static DecodeToken(token: string): TokenData | undefined { - const tokenPayload = jwtDecode(token); - const parseResult = tokenDataSchema.safeParse(tokenPayload); - if (parseResult.success) { - return parseResult.data; - } else console.error("JWT payload broken - " + parseResult.error); - } + public static DecodeToken(token: string): TokenData | undefined { + const tokenPayload = jwtDecode(token); + const parseResult = tokenDataSchema.safeParse(tokenPayload); + if (parseResult.success) { + return parseResult.data; + } else console.error("JWT payload broken - " + parseResult.error); + } } diff --git a/src/features/gameCard/gameCard.tsx b/src/features/gameCard/gameCard.tsx index eae2ebd..e7cfe3a 100644 --- a/src/features/gameCard/gameCard.tsx +++ b/src/features/gameCard/gameCard.tsx @@ -1,32 +1,32 @@ import { GameCardType } from "@/entities/game"; -import Image from "next/image"; +import { Img } from "@/shared/ui"; import Link from "next/link"; export const GameCard = ({ card }: { card: GameCardType }) => { - return ( - - {!!card.cover_preview && ( - - )} -
-

- {card.title} -

- {card.version && ( - - {card.version} - - )} -
-

- {card.description} -

- - ); + return ( + + {!!card.cover && ( + + )} +
+

+ {card.title} +

+ {card.version && ( + + {card.version} + + )} +
+

+ {card.description} +

+ + ); }; diff --git a/src/features/userActivities/userActivities.tsx b/src/features/userActivities/userActivities.tsx index 19d7d70..0858e72 100644 --- a/src/features/userActivities/userActivities.tsx +++ b/src/features/userActivities/userActivities.tsx @@ -6,84 +6,85 @@ import Link from "next/link"; import useSWR from "swr"; import clsx from "clsx"; import { useState } from "react"; +import { TokenData } from "@/entities/user/schemas/auth"; export const UserActivities = () => { - const { data: me } = useSWR("user", () => UserService.IdentifyYourself()); - const [open, changeMenuOpen] = useState(false); + const { data: me } = useSWR("user", () => UserService.IdentifyYourself()); + const [open, changeMenuOpen] = useState(false); - return ( - <> - - - {me && ( -
- -
  • changeMenuOpen(false)} - > - {[ - { - group: "Добавить:", - items: [ - { name: "Добавить игру", link: "/games/add" }, - { name: "Добавить фильм", link: "/films/add" }, - { name: "Добавить аудиокнигу", link: "/audiobooks/add" }, - ], - }, - { name: "Выйти", link: "/logout" }, - ].map((item) => ( -
      - {item.group && ( - <> -
      {item.group}
      -
    • - {item.items.map((item) => ( -
        - - {item.name} - -
      - ))} -
    • - - )} - {!item.group && item.link && ( - - {item.name} - - )} -
    - ))} -
  • -
    - )} - {!me && ( - - Войти - - )} -
    - - ); + return ( + <> + + + {me && ( +
    + +
  • changeMenuOpen(false)} + > + {[ + { + group: "Добавить:", + items: [ + { name: "Добавить игру", link: "/games/add" }, + { name: "Добавить фильм", link: "/films/add" }, + { name: "Добавить аудиокнигу", link: "/audiobooks/add" }, + ], + }, + { name: "Выйти", link: "/logout" }, + ].map((item) => ( +
      + {item.group && ( + <> +
      {item.group}
      +
    • + {item.items.map((item) => ( +
        + + {item.name} + +
      + ))} +
    • + + )} + {!item.group && item.link && ( + + {item.name} + + )} +
    + ))} +
  • +
    + )} + {!me && ( + + Войти + + )} +
    + + ); }; diff --git a/src/shared/assets/icons/index.ts b/src/shared/assets/icons/index.ts index 52b7ead..e6b7ea9 100644 --- a/src/shared/assets/icons/index.ts +++ b/src/shared/assets/icons/index.ts @@ -1,5 +1,6 @@ import { SearchIcon } from "./searchIcon"; import { PersonIcon } from "./personIcon"; import { SunIcon } from "./sunIcon"; +import { SpinnerIcon } from "./spinnerIcon"; -export { SearchIcon, PersonIcon, SunIcon }; +export { SearchIcon, PersonIcon, SunIcon, SpinnerIcon }; diff --git a/src/shared/assets/icons/spinnerIcon.tsx b/src/shared/assets/icons/spinnerIcon.tsx new file mode 100644 index 0000000..8d498d8 --- /dev/null +++ b/src/shared/assets/icons/spinnerIcon.tsx @@ -0,0 +1,28 @@ +export const SpinnerIcon = ({ className }: { className?: string }) => { + return ( + + + + + ); +}; diff --git a/src/shared/ui/image.tsx b/src/shared/ui/image.tsx new file mode 100644 index 0000000..da16a36 --- /dev/null +++ b/src/shared/ui/image.tsx @@ -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 ( + {alt + ); +}; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index f8d5740..536717f 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,3 +1,4 @@ import { Modal } from "./modal"; +import { Img } from "./image"; -export { Modal }; +export { Modal, Img }; diff --git a/src/shared/utils/http.ts b/src/shared/utils/http.ts index 154238a..67a4609 100644 --- a/src/shared/utils/http.ts +++ b/src/shared/utils/http.ts @@ -4,57 +4,61 @@ import { z } from "zod"; type Body = BodyInit | object; export abstract class HTTPService { - public static async request( - method: "GET" | "POST" | "PUT" | "DELETE", - url: string, - schema: Z, - body?: Body, - headers?: HeadersInit, - stringify?: boolean - ) { - return await fetch(process.env.NEXT_PUBLIC_BASE_URL + url, { - method: method, - headers: { - accept: "application/json", - Authorization: "Bearer " + UserService.GetToken(), - ...headers, - }, - body: stringify ? JSON.stringify(body) : (body as BodyInit), - cache: "no-cache", - }) - .then((r) => { - if (r && r.ok) return r; - else throw Error("Response ok = false"); - }) - .then((r) => r.json()) - .then((d) => schema.parse(d) as z.infer) - .catch((e) => { - console.error(e); - return null; - }); - } + public static async request( + method: "GET" | "POST" | "PUT" | "DELETE", + url: string, + schema: Z, + body?: Body, + headers?: HeadersInit, + stringify?: boolean + ) { + return await fetch(process.env.NEXT_PUBLIC_BASE_URL + url, { + method: method, + headers: { + accept: "application/json", + ...((stringify ?? true) != true + ? {} + : { "Content-Type": "application/json" }), + Authorization: "Bearer " + UserService.GetToken(), + ...headers, + }, + body: + (stringify ?? true) != true ? (body as BodyInit) : JSON.stringify(body), + cache: "no-cache", + }) + .then((r) => { + if (r && r.ok) return r; + else throw Error("Response ok = false"); + }) + .then((r) => r.json()) + .then((d) => schema.parse(d) as z.infer) + .catch((e) => { + console.error(e); + return null; + }); + } - public static async get(url: string, schema: Z) { - return await this.request("GET", url, schema); - } + public static async get(url: string, schema: Z) { + return await this.request("GET", url, schema); + } - public static async post( - url: string, - schema: Z, - body?: Body, - headers?: HeadersInit, - stringify?: boolean - ) { - return await this.request("POST", url, schema, body, headers, stringify); - } + public static async post( + url: string, + schema: Z, + body?: Body, + headers?: HeadersInit, + stringify?: boolean + ) { + return await this.request("POST", url, schema, body, headers, stringify); + } - public static async put( - url: string, - schema: Z, - body?: Body, - headers?: HeadersInit, - stringify?: boolean - ) { - return await this.request("PUT", url, schema, body, headers, stringify); - } + public static async put( + url: string, + schema: Z, + body?: Body, + headers?: HeadersInit, + stringify?: boolean + ) { + return await this.request("PUT", url, schema, body, headers, stringify); + } } diff --git a/src/widgets/gameInfo/gameInfo.tsx b/src/widgets/gameInfo/gameInfo.tsx index 97a5aa3..b179817 100644 --- a/src/widgets/gameInfo/gameInfo.tsx +++ b/src/widgets/gameInfo/gameInfo.tsx @@ -1,184 +1,273 @@ "use client"; import { - gameCreateSchema, - GameCreateType, - GameService, - GameType, + gameCreateSchema, + GameCreateType, + GameService, + GameType, } from "@/entities/game"; import clsx from "clsx"; -import Image from "next/image"; import Link from "next/link"; import { getYouTubeID } from "@/shared/utils"; import { UserService } from "@/entities/user"; import useSWR from "swr"; -import { useEffect, useState } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; 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 }) => { - const { data: me } = useSWR("user", () => UserService.IdentifyYourself()); - const [editable, setEditable] = useState(false); + const { data: me } = useSWR("user", () => UserService.IdentifyYourself()); + const [editable, setEditable] = useState(false); - useEffect(() => setEditable(me?.id === game.owner_id), [me, game]); + useEffect(() => setEditable(me?.id === game.owner_id), [me, game]); - const { - register, - handleSubmit, - setValue, - formState: { errors }, - } = useForm({ - resolver: zodResolver(gameCreateSchema), - }); + const { + register, + handleSubmit, + setValue, + watch, + formState: { dirtyFields, errors }, + } = useForm({ + resolver: zodResolver(gameCreateSchema), + }); - useEffect(() => { - register("torrent_file", { value: game.torrent_file }); - register("cover", { value: game.cover }); - }, []); + useEffect(() => { + register("torrent_file", { value: game.torrent_file }); + register("cover", { value: game.cover }); + }, []); - useEffect(() => { - console.log(errors); - }, [errors]); + const [savedTimeout, changeSavedTimeout] = useState( + null + ); + const watchedData = watch(); + const [formData, changeFormData] = useState(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 updatedGame = GameService.changeGame(game.id, formData); - console.log(updatedGame); - }; + const onSubmit = async (formData: GameCreateType) => { + changeSavedTimeout(null); + const updatedGame = await GameService.changeGame(game.id, formData); + console.log(updatedGame); + }; - return ( -
    - {game.cover && ( -
    - -
    - )} - -

    { - setValue("title", e.currentTarget.innerText, { - shouldValidate: true, - }); - console.log(); - }} - > - {game.title} -

    - {game.description && ( -
    { - setValue("description", e.currentTarget.innerText, { - shouldValidate: true, - }); - }} - > - {game.description} -
    - )} -
    -
    - {[ - [ - { name: "Система", key: "system" }, - { name: "Процессор", key: "processor" }, - { name: "Оперативная память", key: "memory" }, - { name: "Видеокарта", key: "graphics" }, - { name: "Место на диске", key: "storage" }, - ], - [ - { name: "Версия игры", key: "version" }, - { - name: "Дата обновления раздачи", - key: "update_date", - value: game.update_date.toLocaleDateString("ru-ru"), - }, - { name: "Язык", key: "language" }, - { name: "Разработчик", key: "developer" }, - { - name: "Год выхода", - key: "release_date", - value: game.release_date.toLocaleDateString("en-us", { - year: "numeric", - }), - }, - { name: "Объём загрузки", key: "download_size" }, - ], - ].map((section, i) => ( -
      - {section.map((req) => ( -
    • - {req.name + ": "} - -
    • - ))} -
    - ))} -
    - {game.trailer && getYouTubeID(game.trailer) && ( -