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

View File

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

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 { 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<typeof gameCreateSchema>;
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<typeof gameSchema>;
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;
});

View File

@@ -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<typeof gameCardSchema>;
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;
});

View File

@@ -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<typeof userSchema>;

View File

@@ -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);
}
}

View File

@@ -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 (
<Link className="group/gamecard cursor-pointer" href={"/games/" + card.id}>
{!!card.cover_preview && (
<Image
src={card.cover_preview}
className="rounded-lg object-contain"
alt=""
width={1280}
height={720}
/>
)}
<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">
{card.title}
</h2>
{card.version && (
<span className="text-xs max-w-[30%] text-right line-clamp-2 text-fg4">
{card.version}
</span>
)}
</div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{card.description}
</p>
</Link>
);
return (
<Link className="group/gamecard cursor-pointer" href={"/games/" + card.id}>
{!!card.cover && (
<Img
src={card.cover}
preview={true}
className="rounded-lg object-contain"
width={1280}
height={720}
/>
)}
<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">
{card.title}
</h2>
{card.version && (
<span className="text-xs max-w-[30%] text-right line-clamp-2 text-fg4">
{card.version}
</span>
)}
</div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{card.description}
</p>
</Link>
);
};

View File

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

View File

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

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 { Img } from "./image";
export { Modal };
export { Modal, Img };

View File

@@ -4,57 +4,61 @@ import { z } from "zod";
type Body = BodyInit | object;
export abstract class HTTPService {
public static async request<Z extends z.ZodTypeAny>(
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<Z>)
.catch((e) => {
console.error(e);
return null;
});
}
public static async request<Z extends z.ZodTypeAny>(
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<Z>)
.catch((e) => {
console.error(e);
return null;
});
}
public static async get<Z extends z.ZodTypeAny>(url: string, schema: Z) {
return await this.request<Z>("GET", url, schema);
}
public static async get<Z extends z.ZodTypeAny>(url: string, schema: Z) {
return await this.request<Z>("GET", url, schema);
}
public static async post<Z extends z.ZodTypeAny>(
url: string,
schema: Z,
body?: Body,
headers?: HeadersInit,
stringify?: boolean
) {
return await this.request<Z>("POST", url, schema, body, headers, stringify);
}
public static async post<Z extends z.ZodTypeAny>(
url: string,
schema: Z,
body?: Body,
headers?: HeadersInit,
stringify?: boolean
) {
return await this.request<Z>("POST", url, schema, body, headers, stringify);
}
public static async put<Z extends z.ZodType>(
url: string,
schema: Z,
body?: Body,
headers?: HeadersInit,
stringify?: boolean
) {
return await this.request<Z>("PUT", url, schema, body, headers, stringify);
}
public static async put<Z extends z.ZodType>(
url: string,
schema: Z,
body?: Body,
headers?: HeadersInit,
stringify?: boolean
) {
return await this.request<Z>("PUT", url, schema, body, headers, stringify);
}
}

View File

@@ -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<boolean>(false);
const { data: me } = useSWR("user", () => UserService.IdentifyYourself());
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 {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<GameCreateType>({
resolver: zodResolver(gameCreateSchema),
});
const {
register,
handleSubmit,
setValue,
watch,
formState: { dirtyFields, errors },
} = useForm<GameCreateType>({
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<NodeJS.Timeout | null>(
null
);
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 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 (
<form
onSubmit={handleSubmit(onSubmit)}
className="p-4 flex flex-col lp:block"
>
{game.cover && (
<div className="lp:w-[60%] lp:px-4 lp:pl-0 py-2 float-left">
<Image
src={game.cover}
className="rounded-lg w-full object-contain"
alt=""
width={1280}
height={720}
/>
</div>
)}
<span className="lp:max-w-[40%]">
<h1
className={clsx(
"text-4xl outline-none",
!editable && "cursor-default"
)}
suppressContentEditableWarning={true}
contentEditable={editable}
{...register("title", { value: game.title })}
onInput={(e) => {
setValue("title", e.currentTarget.innerText, {
shouldValidate: true,
});
console.log();
}}
>
{game.title}
</h1>
{game.description && (
<div
contentEditable={editable}
suppressContentEditableWarning={true}
className={clsx(
"text-md text-justify",
"text-fg4 pt-2 outline-none",
!editable && "cursor-default"
)}
{...register("description", { value: game.description })}
onInput={(e) => {
setValue("description", e.currentTarget.innerText, {
shouldValidate: true,
});
}}
>
{game.description}
</div>
)}
</span>
<div
className={clsx(
"w-full flex justify-between pt-4",
!editable && "cursor-default"
)}
>
{[
[
{ 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) => (
<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 && "text-fg4"
)}
{...register(req.key as keyof GameCreateType)}
defaultValue={
req.value ??
(game[req.key as keyof GameType] as string) ??
"Не известно"
}
></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=" m-2 p-2 bg-ac0" />
</form>
);
const [cover, setCover] = useState<string | undefined>(game.cover);
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0];
const fileReader = new FileReader();
fileReader.onload = async () => {
const coverName = await FilesService.UploadCover(file);
if (coverName) {
setCover(coverName);
setValue("cover", coverName);
}
};
fileReader.readAsDataURL(file);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
const formRef = useRef<HTMLFormElement>(null);
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="p-4 flex flex-col lp:block"
ref={formRef}
>
{cover && (
<div
className="lp:w-[60%] lp:px-4 lp:pl-0 pt-2 float-left"
{...(editable ? getRootProps() : {})}
>
<Img
src={cover}
preview={false}
className="transition-all rounded-lg w-full object-contain"
width={1280}
height={720}
/>
{editable && (
<>
<input {...getInputProps()} />
<span className="flex items-center ju w-full p-1">
{isDragActive ? (
<p>Изменить обложку...</p>
) : (
<span className="text-sm w-full flex justify-around">
Для редактирования нажмите или перетащите новую обложку
поверх старой
</span>
)}
</span>
</>
)}
</div>
)}
<span className="lp:max-w-[40%]">
<span className="flex items-end justify-between">
<h1
className={clsx(
"text-4xl outline-none max-w-[80%]",
!editable && "cursor-default"
)}
suppressContentEditableWarning={true}
contentEditable={editable}
{...register("title", { value: game.title })}
onInput={(e) => {
setValue("title", e.currentTarget.innerText, {
shouldValidate: true,
shouldDirty: true,
});
console.log();
}}
>
{game.title}
</h1>
<span className="text-sm text-fg4 flex items-center">
{savedTimeout && (
<>
<SpinnerIcon className="mr-2" />
Редактируется
</>
)}
{!savedTimeout && "Сохранено"}
</span>
</span>
{game.description && (
<div
contentEditable={editable}
suppressContentEditableWarning={true}
className={clsx(
"text-md text-justify",
"text-fg4 pt-2 outline-none",
!editable && "cursor-default"
)}
{...register("description", { value: game.description })}
onInput={(e) => {
setValue("description", e.currentTarget.innerText, {
shouldValidate: true,
shouldDirty: true,
});
}}
>
{game.description}
</div>
)}
</span>
<div
className={clsx(
"w-full flex justify-between pt-4",
!editable && "cursor-default"
)}
>
{(
[
[
{ 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" },
],
] 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>
);
};