Work on game edit

This commit is contained in:
2024-05-26 13:55:34 +04:00
parent 2572c43733
commit 8b6246a38c
16 changed files with 405 additions and 217 deletions

View File

@@ -35,8 +35,7 @@ export default function Login() {
className="flex flex-col items-center justify-evenly" className="flex flex-col items-center justify-evenly"
> >
<h2 className="pb-4 text-4xl">.Torrent</h2> <h2 className="pb-4 text-4xl">.Torrent</h2>
{(["username", "password"] as ("username" | "password")[]).map( {(["username", "password"] as (keyof LoginForm)[]).map((field) => (
(field) => (
<label <label
className="flex flex-col items-start relative w-64 py-1" className="flex flex-col items-start relative w-64 py-1"
key={field} key={field}
@@ -59,8 +58,7 @@ export default function Login() {
{errors[field]?.message} {errors[field]?.message}
</p> </p>
</label> </label>
) ))}
)}
<input <input
type="submit" type="submit"

View File

@@ -1,10 +1,7 @@
import { GameService } from "@/entities/game"; import { GameService } from "@/entities/game";
import { GameCard } from "@/features/gameCard"; import { GameCard } from "@/features/gameCard";
import { getYouTubeID } from "@/shared/utils"; import { GameInfo } from "@/widgets/gameInfo";
import { Section } from "@/widgets/section"; import { Section } from "@/widgets/section";
import clsx from "clsx";
import Image from "next/image";
import Link from "next/link";
export default async function Games({ export default async function Games({
params: { game_id }, params: { game_id },
@@ -15,104 +12,7 @@ export default async function Games({
const game = await GameService.getGame(game_id); const game = await GameService.getGame(game_id);
return ( return (
<> <>
{game && ( {game && <GameInfo game={game} />}
<div 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="text-4xl">{game.title}</h1>
{game.description && (
<p className="text-md text-justify text-fg4 pt-2">
{game.description}
</p>
)}
</span>
<div className="w-full flex justify-between pt-4">
{[
[
{ name: "Система", value: game.system },
{ name: "Процессор", value: game.processor },
{ name: "Оперативная память", value: game.memory },
{ name: "Видеокарта", value: game.graphics },
{ name: "Место на диске", value: game.storage },
],
[
{
name: "Версия игры",
value: `${
game.version
} (обновлена ${game.update_date.toLocaleDateString(
"ru-ru"
)})`,
},
{ name: "Язык", value: game.language },
{ name: "Разработчик", value: game.developer },
{
name: "Год выхода",
value: game.release_date.toLocaleDateString("en-us", {
year: "numeric",
}),
},
{ name: "Объём загрузки", value: game.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 + ": "}
<span
className={clsx(
"font-normal",
req.value === undefined && "text-fg4"
)}
>
{req.value ?? "Не известно"}
</span>
</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>
</div>
)}
{gameCards && ( {gameCards && (
<Section <Section

View File

@@ -52,8 +52,14 @@ body {
overflow: hidden; overflow: hidden;
color: var(--color-fg1); color: var(--color-fg1);
background-color: var(--color-bg0); background-color: var(--color-bg0);
} }
body * {
scrollbar-color: var(--color-ac0) var(--color-bg1);
scrollbar-width: thin;
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
:root { :root {

View File

@@ -19,7 +19,10 @@ export default function RootLayout({
<ThemeProvider enableSystem={false} defaultTheme="light"> <ThemeProvider enableSystem={false} defaultTheme="light">
{auth} {auth}
<Header /> <Header />
<div className="w-full h-full max-w-[var(--app-width)] m-auto overflow-y-auto"> <div
className="w-full h-[calc(100%_-_5rem)] \
max-w-[var(--app-width)] m-auto overflow-y-auto"
>
{children} {children}
</div> </div>
</ThemeProvider> </ThemeProvider>

View File

@@ -1,15 +1,16 @@
import { HTTPService } from "@/shared/utils/http"; import { HTTPService } from "@/shared/utils/http";
import { gameCardsSchema, GameCardType } from "./schemas/gameCard"; import { gameCardsSchema, GameCardType } from "./schemas/gameCard";
import { gameSchema, GameType } from "./schemas/game"; import { GameCreateType, gameSchema } from "./schemas/game";
import { z } from "zod";
export abstract class GameService { export abstract class GameService {
public static async getGameCards() { public static async getGameCards() {
return await HTTPService.get<GameCardType[]>( return await HTTPService.get("/games/cards", gameCardsSchema);
"/games/cards",
gameCardsSchema
);
} }
public static async getGame(id: number) { public static async getGame(id: number) {
return await HTTPService.get<GameType>(`/games/${id}`, gameSchema); return await HTTPService.get(`/games/${id}`, gameSchema);
}
public static async changeGame(id: number, gameInfo: GameCreateType) {
return await HTTPService.put(`/games/${id}`, gameSchema, gameInfo);
} }
} }

View File

@@ -1,5 +1,11 @@
import { GameCardType } from "./schemas/gameCard"; import { GameCardType } from "./schemas/gameCard";
import { GameType } from "./schemas/game"; import { GameType, GameCreateType, gameCreateSchema } from "./schemas/game";
import { GameService } from "./game"; import { GameService } from "./game";
export { GameService, type GameType, type GameCardType }; export {
GameService,
gameCreateSchema,
type GameType,
type GameCreateType,
type GameCardType,
};

View File

@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { gameCardSchema } from "./gameCard"; import { gameCardBaseSchema } from "./gameCard";
export const gameSchema = gameCardSchema.and( export const gameBaseSchema = gameCardBaseSchema.and(
z.object({ z.object({
torrent_file: z.string().min(1), torrent_file: z.string().min(1),
trailer: z.string().optional(), trailer: z.string().optional(),
@@ -16,6 +16,20 @@ export const gameSchema = gameCardSchema.and(
language: z.string().optional(), language: z.string().optional(),
download_size: z.string().optional(), download_size: z.string().optional(),
release_date: z
.string()
.min(1)
.transform((d) => new Date(d)),
})
);
export const gameCreateSchema = gameBaseSchema.and(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 update_date: z
.string() .string()
.min(1) .min(1)
@@ -24,10 +38,6 @@ export const gameSchema = gameCardSchema.and(
.string() .string()
.min(1) .min(1)
.transform((d) => new Date(d)), .transform((d) => new Date(d)),
release_date: z
.string()
.min(1)
.transform((d) => new Date(d)),
}) })
); );
export type GameType = z.infer<typeof gameSchema>; export type GameType = z.infer<typeof gameSchema>;

View File

@@ -1,8 +1,7 @@
import { z } from "zod"; import { z } from "zod";
export const gameCardSchema = z export const gameCardBaseSchema = z
.object({ .object({
id: z.number().positive(),
title: z.string().min(3), title: z.string().min(3),
cover: z.string().optional(), cover: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
@@ -19,6 +18,12 @@ export const gameCardSchema = z
: undefined, : undefined,
}; };
}); });
export const gameCardSchema = gameCardBaseSchema.and(
z.object({
id: z.number().positive(),
})
);
export type GameCardType = z.infer<typeof gameCardSchema>; export type GameCardType = z.infer<typeof gameCardSchema>;
export const isGameCard = (a: any): a is GameCardType => { export const isGameCard = (a: any): a is GameCardType => {

View File

@@ -11,10 +11,10 @@ import Cookies from "js-cookie";
export abstract class UserService { export abstract class UserService {
public static async Login(loginForm: LoginForm) { public static async Login(loginForm: LoginForm) {
const accessToken = await HTTPService.post<TokenResponse>( const accessToken = await HTTPService.post(
"/auth", "/auth",
new URLSearchParams(Object.entries(loginForm)),
tokenResponseSchema, tokenResponseSchema,
new URLSearchParams(Object.entries(loginForm)),
{ {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
} }
@@ -31,6 +31,10 @@ export abstract class UserService {
} }
} }
public static GetToken(): string | undefined {
return Cookies.get("access-token");
}
public static IdentifyYourself(): TokenData | undefined { public static IdentifyYourself(): TokenData | undefined {
const token = Cookies.get("access-token"); const token = Cookies.get("access-token");
if (token) { if (token) {

View File

@@ -15,7 +15,7 @@ export const GameCard = ({ card }: { card: GameCardType }) => {
/> />
)} )}
<div className="flex items-center justify-between pr-2"> <div className="flex items-center justify-between pr-2">
<h2 className="text-3xl tb:text-xl py-1 group-hover/gamecard:underline underline-offset-4"> <h2 className="text-3xl tb:text-xl py-1 group-hover/gamecard:underline underline-offset-1">
{card.title} {card.title}
</h2> </h2>
{card.version && ( {card.version && (

View File

@@ -4,26 +4,86 @@ import { UserService } from "@/entities/user";
import { PersonIcon } from "@/shared/assets/icons"; import { PersonIcon } from "@/shared/assets/icons";
import Link from "next/link"; import Link from "next/link";
import useSWR from "swr"; import useSWR from "swr";
import clsx from "clsx";
import { useState } from "react";
export const UserActivities = () => { export const UserActivities = () => {
const { data: me } = useSWR("user", () => UserService.IdentifyYourself()); const { data: me } = useSWR("user", () => UserService.IdentifyYourself());
const [open, changeMenuOpen] = useState<boolean>(false);
return ( return (
<> <>
<span className="group/login cursor-pointer flex items-center">
<PersonIcon className="mr-1 h-4 w-4" /> <PersonIcon className="mr-1 h-4 w-4" />
{me && ( {me && (
<span className="group/login cursor-pointer flex items-center"> <div className="relative">
<span className="group-hover/login:underline">{me.name}</span> <button
</span> 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 && ( {!me && (
<Link <Link href="/login" className="cursor-pointer flex items-center">
href="/login"
className="group/login cursor-pointer flex items-center"
>
<span className="group-hover/login:underline">Войти</span> <span className="group-hover/login:underline">Войти</span>
</Link> </Link>
)} )}
</span>
</> </>
); );
}; };

View File

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

View File

@@ -0,0 +1,184 @@
"use client";
import {
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 { zodResolver } from "@hookform/resolvers/zod";
export const GameInfo = ({ game }: { game: GameType }) => {
const { data: me } = useSWR("user", () => UserService.IdentifyYourself());
const [editable, setEditable] = useState<boolean>(false);
useEffect(() => setEditable(me?.id === game.owner_id), [me, game]);
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<GameCreateType>({
resolver: zodResolver(gameCreateSchema),
});
useEffect(() => {
register("torrent_file", { value: game.torrent_file });
register("cover", { value: game.cover });
}, []);
useEffect(() => {
console.log(errors);
}, [errors]);
const onSubmit = (formData: GameCreateType) => {
const updatedGame = 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>
);
};

View File

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

View File

@@ -50,6 +50,7 @@ export const Header = () => {
</span> </span>
<label className="flex flex-col items-start relative w-36"> <label className="flex flex-col items-start relative w-36">
<input <input
type="search"
className="peer/search w-full rounded-lg bg-bg4 px-2" className="peer/search w-full rounded-lg bg-bg4 px-2"
placeholder=" " placeholder=" "
/> />

View File

@@ -14,7 +14,7 @@ export const MobileMenu = ({
return ( return (
<div className="relative"> <div className="relative">
<button <button
className="peer w-16 h-16 *:w-12 *:h-1 *:bg-fg1 *:my-3 className="w-16 h-16 *:w-12 *:h-1 *:bg-fg1 *:my-3
*:transition-all *:duration-300 *:relative" *:transition-all *:duration-300 *:relative"
onClick={() => changeMenuOpen(!open)} onClick={() => changeMenuOpen(!open)}
onBlur={() => changeMenuOpen(false)} onBlur={() => changeMenuOpen(false)}
@@ -30,7 +30,7 @@ export const MobileMenu = ({
<div <div
className={clsx( className={clsx(
"h-0 absolute transition-all duration-300 overflow-hidden\ "h-0 absolute transition-all duration-300 overflow-hidden\
bg-bg4 rounded-lg px-4 flex flex-col", bg-bg4 rounded-lg px-4 flex flex-col shadow-xl",
open && "h-32" open && "h-32"
)} )}
onClick={() => changeMenuOpen(false)} onClick={() => changeMenuOpen(false)}