mirror of
https://github.com/StepanovPlaton/torrent_frontend.git
synced 2026-04-03 12:20:48 +04:00
Work on game form
This commit is contained in:
41
package-lock.json
generated
41
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
16
src/entities/files/files.ts
Normal file
16
src/entities/files/files.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
3
src/entities/files/index.ts
Normal file
3
src/entities/files/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FilesService } from "./files";
|
||||
|
||||
export { FilesService };
|
||||
4
src/entities/files/schemas/cover.ts
Normal file
4
src/entities/files/schemas/cover.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const coverNameSchema = z.string().min(5);
|
||||
export type CoverNameType = z.infer<typeof coverNameSchema>;
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
28
src/shared/assets/icons/spinnerIcon.tsx
Normal file
28
src/shared/assets/icons/spinnerIcon.tsx
Normal 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
33
src/shared/ui/image.tsx
Normal 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 ?? ""}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Modal } from "./modal";
|
||||
import { Img } from "./image";
|
||||
|
||||
export { Modal };
|
||||
export { Modal, Img };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user