Complete game page and add how_to_download page

This commit is contained in:
2024-05-12 20:06:41 +04:00
parent 15247adfa3
commit ab6eca4661
14 changed files with 410 additions and 267 deletions

View File

@@ -1,3 +1,4 @@
NEXT_PUBLIC_BASE_URL=http://127.0.0.1:3000/api NEXT_PUBLIC_BASE_URL=http://127.0.0.1:3000/api
NEXT_PUBLIC_CONTENT_URL=http://127.0.0.1:8000/content/torrent
NEXT_PUBLIC_COVER_FULL_URL=http://127.0.0.1:8000/content/images/cover/full_size NEXT_PUBLIC_COVER_FULL_URL=http://127.0.0.1:8000/content/images/cover/full_size
NEXT_PUBLIC_COVER_PREVIEW_URL=http://127.0.0.1:8000/content/images/cover/preview NEXT_PUBLIC_COVER_PREVIEW_URL=http://127.0.0.1:8000/content/images/cover/preview

View File

@@ -1,48 +1,126 @@
import { GameService } from "@/entities/game"; import { GameService } from "@/entities/game";
import { GameCard } from "@/features/gameCard"; import { GameCard } from "@/features/gameCard";
import { Section } from "@/widgets/section"; import { Section } from "@/widgets/section";
import clsx from "clsx";
import Image from "next/image"; 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 },
}: { }: {
params: { game_id: number }; params: { game_id: number };
}) { }) {
const gameCards = await GameService.getGameCards(); const gameCards = await GameService.getGameCards();
const game = await GameService.getGame(game_id); const game = await GameService.getGame(game_id);
return ( return (
<> <>
{game && ( {game && (
<div className="p-4 flex flex-col lp:flex-row"> <div className="p-4 flex flex-col lp:block">
{game.cover && ( {game.cover && (
<Image <div className="lp:w-[60%] lp:px-4 lp:pl-0 pt-2 float-left">
src={game.cover} <Image
className="rounded-lg w-[60%] aspect-video object-cover" src={game.cover}
alt="" className="rounded-lg aspect-video object-cover"
width={1280} alt=""
height={720} width={1280}
/> height={720}
)} />
<div className="pt-2 max-w-[40%]"> </div>
<h1 className="text-4xl">{game.title}</h1> )}
<p className="text-md text-justify text-fg4 pt-2"> <span className="pt-2 lp:max-w-[40%]">
{game.description} <h1 className="text-4xl">{game.title}</h1>
</p> {game.description && (
</div> <p className="text-md text-justify text-fg4 pt-2">
</div> {game.description}
)} </p>
)}
</span>
<div className="flex justify-between pt-6">
{[
[
{ 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 && (
<iframe
src={game.trailer.replace("/watch?v=", "/embed/")}
className="w-full aspect-video rounded-lg my-4"
allowFullScreen
/>
)}
<div className="relative w-full flex items-center justify-around">
<Link
href={
process.env.NEXT_PUBLIC_CONTENT_URL + "/" + game.torrent_file
}
className="p-4 bg-ac0 text-fg1 text-xl rounded-lg"
>
Скачать {game.title}.torrent
</Link>
</div>
<div className="w-full flex justify-end">
<Link className="text-right text-sm" href="/how_to_download">
Как скачать игру
<br /> с помощью .torrent файла?
</Link>
</div>
</div>
)}
{gameCards && ( {gameCards && (
<Section <Section
name="Другие популярные игры" name="Другие популярные игры"
link="/games" link="/games"
invite_text={'Перейти в раздел "Игры"'} invite_text={'Перейти в раздел "Игры"'}
> >
{gameCards.map((card) => ( {gameCards.map((card) => (
<GameCard key={card.id} card={card} /> <GameCard key={card.id} card={card} />
))} ))}
</Section> </Section>
)} )}
</> </>
); );
} }

View File

@@ -4,22 +4,22 @@ import { Section } from "@/widgets/section";
import { Metadata } from "next"; import { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: ".Torrent: Игры", title: ".Torrent: Игры",
description: description:
".Torrent: Игры - каталог .torrent файлов для обмена видеоиграми", ".Torrent: Игры - каталог .torrent файлов для обмена видеоиграми",
}; };
export default async function Games() { export default async function Games() {
const gameCards = await GameService.getGameCards(); const gameCards = await GameService.getGameCards();
return ( return (
<> <>
{gameCards && ( {gameCards && gameCards.length > 0 && (
<Section> <Section>
{gameCards.map((card) => ( {gameCards.map((card) => (
<GameCard key={card.id} card={card} /> <GameCard key={card.id} card={card} />
))} ))}
</Section> </Section>
)} )}
</> </>
); );
} }

View File

@@ -11,8 +11,12 @@
--color-fg1: #3c3836; --color-fg1: #3c3836;
--color-fg4: #7c6f64; --color-fg4: #7c6f64;
--color-ac0: #83a598;
--color-ac1: #fabd2f;
--color-ac2: #8ec07c;
--app-width: 70%; --app-width: 70%;
font-size: calc((100vh / 1080) * 24); font-size: calc((100vw / 1920) * 24);
} }
[data-theme="dark"] { [data-theme="dark"] {
@@ -23,6 +27,10 @@
--color-fg0: #fbf1c7; --color-fg0: #fbf1c7;
--color-fg1: #ebdbb2; --color-fg1: #ebdbb2;
--color-fg4: #a89984; --color-fg4: #a89984;
--color-ac0: #076678;
--color-ac1: #b57614;
--color-ac2: #427b58;
} }
html, html,
@@ -37,7 +45,7 @@ body {
transition-property: color, background-color, border-color; transition-property: color, background-color, border-color;
transition-duration: 0.3s; transition-duration: 0.3s;
overflow: hidden; /* overflow: hidden; */
color: var(--color-fg1); color: var(--color-fg1);
background-color: var(--color-bg0); background-color: var(--color-bg0);
} }
@@ -48,8 +56,13 @@ body {
} }
} }
@media (max-width: 1024px) {
:root {
font-size: calc((100vw / 1920) * 56);
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
:root { :root {
font-size: calc((100vh / 1080) * 16); font-size: calc((100vw / 1920) * 64);
} }
} }

View File

@@ -0,0 +1,61 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: ".Torrent: Как скачать?",
description:
".Torrent: Как скачать? - краткое руководство по скачиваю данных с помощью .torrent файлов",
};
export default async function HowToDownload() {
return (
<div className="w-full flex flex-col lp:flex-row justify-between p-4">
<div className="w-full p-4 lp:w-[50%] lp:pr-10">
<h1 className="text-4xl lp:text-3xl">Как скачать?</h1>
<div className="text-fg4 text-justify pt-2">
Чтобы скачать что-либо с помощью торрент-файла, выполните следующие
шаги:
<ul className="*:text-fg4">
<li>
1. Найдите и загрузите торрент-файл или magnet-ссылку, содержащую
информацию о файле, который вы хотите скачать.
</li>
<li>
2. Откройте программу-клиент для загрузки торрентов, например,
uTorrent, BitTorrent или qBittorrent.
</li>
<li>
3. В программе-клиенте выберите опцию "Open Torrent File" или "Add
Torrent" и выберите торрент-файл, который вы скачали в первом
шаге.
</li>
<li>
4. После этого начнется загрузка файлов, указанных в
торрент-файле. Вы также можете выбрать папку, куда сохранять
скачанные файлы.
</li>
<li>
5. Дождитесь завершения загрузки файлов. После этого вы сможете
открыть и использовать скачанные файлы на своем компьютере.
</li>
</ul>
</div>
</div>
<div className="w-full p-4 lp:w-[50%] lp:pl-10">
<h2 className="text-4xl lp:text-3xl">Что такое .torrent файл?</h2>
<p className="text-fg4 text-justify pt-2">
Торрент-файл (или .torrent-файл) - это небольшой файл, который
содержит метаданные о файле или наборе файлов, которые можно загрузить
с помощью протокола BitTorrent. В торрент-файле обычно указан адрес
трекера (специального сервера, отслеживающего пиров) и хеш-суммы
частей файлов, которые необходимы для скачивания.
<br />
<br /> Пользователь, желающий загрузить файл через BitTorrent, сначала
скачивает торрент-файл или magnet-ссылку, загружает ее в
торрент-клиент (программу для скачивания торрентов), и затем начинает
загрузку файлов, участвуя в обмене данными с другими пользователями
(пирами) через сеть BitTorrent.
</p>
</div>
</div>
);
}

View File

@@ -7,27 +7,27 @@ import { Header } from "@/widgets/header";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: ".Torrent", title: ".Torrent",
description: description:
".Torrent - сервис обмена .torrent файлами видеоигр, фильмов и аудиокниг", ".Torrent - сервис обмена .torrent файлами видеоигр, фильмов и аудиокниг",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
// suppressHydrationWarning for theme support // suppressHydrationWarning for theme support
<html lang="ru" suppressHydrationWarning> <html lang="ru" suppressHydrationWarning>
<body className={inter.className}> <body className={inter.className}>
<ThemeProvider enableSystem={false} defaultTheme="light"> <ThemeProvider enableSystem={false} defaultTheme="light">
<Header /> <Header />
<div className="w-full h-full max-w-[var(--app-width)] m-auto overflow-y-auto"> <div className="w-full h-full max-w-[var(--app-width)] m-auto">
{children} {children}
</div> </div>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
); );
} }

View File

@@ -3,20 +3,20 @@ import { GameCard } from "@/features/gameCard";
import { Section } from "@/widgets/section"; import { Section } from "@/widgets/section";
export default async function Home() { export default async function Home() {
const gameCards = await GameService.getGameCards(); const gameCards = await GameService.getGameCards();
return ( return (
<> <>
{gameCards && ( {gameCards && gameCards.length > 0 && (
<Section <Section
name="Игры" name="Игры"
link="/games" link="/games"
invite_text={'Перейти в раздел "Игры"'} invite_text={'Перейти в раздел "Игры"'}
> >
{gameCards.map((card) => ( {gameCards.map((card) => (
<GameCard key={card.id} card={card} /> <GameCard key={card.id} card={card} />
))} ))}
</Section> </Section>
)} )}
</> </>
); );
} }

View File

@@ -2,33 +2,45 @@ import { z } from "zod";
import { gameCardSchema } from "./gameCard"; import { gameCardSchema } from "./gameCard";
export const gameSchema = gameCardSchema.and( export const gameSchema = gameCardSchema.and(
z.object({ z.object({
torrent_file: z.string().min(1), torrent_file: z.string().min(1),
language: z.string().optional(), trailer: z.string().optional(),
version: z.string().optional(),
download_size: z.string().optional(), system: z.string().optional(),
system: z.string().optional(), processor: z.string().optional(),
processor: z.string().optional(), memory: z.string().optional(),
memory: z.string().optional(), graphics: z.string().optional(),
graphics: z.string().optional(), storage: z.string().optional(),
storage: z.string().optional(),
upload_date: z developer: z.string().optional(),
.string() language: z.string().optional(),
.min(1) download_size: z.string().optional(),
.transform((d) => new Date(d)),
}) update_date: z
.string()
.min(1)
.transform((d) => new Date(d)),
upload_date: z
.string()
.min(1)
.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>;
export const isGame = (a: any): a is GameType => { export const isGame = (a: any): a is GameType => {
return gameSchema.safeParse(a).success; return gameSchema.safeParse(a).success;
}; };
export const gamesSchema = z.array(z.any()).transform((a) => { export const gamesSchema = z.array(z.any()).transform((a) => {
const games: GameType[] = []; const games: GameType[] = [];
a.forEach((e) => { a.forEach((e) => {
if (isGame(e)) games.push(gameSchema.parse(e)); if (isGame(e)) games.push(gameSchema.parse(e));
else console.error("Game parse error - ", e); else console.error("Game parse error - ", e);
}); });
return games; return games;
}); });

View File

@@ -1,38 +1,35 @@
import { z } from "zod"; import { z } from "zod";
export const gameCardSchema = z export const gameCardSchema = z
.object({ .object({
id: z.number(), id: z.number(),
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(),
release_date: z version: z.string().optional(),
.string() })
.min(1) .transform((card) => {
.transform((d) => new Date(d)), return {
}) ...card,
.transform((card) => { cover: card.cover
return { ? process.env.NEXT_PUBLIC_COVER_FULL_URL + "/" + card.cover
...card, : undefined,
cover: card.cover cover_preview: card.cover
? process.env.NEXT_PUBLIC_COVER_FULL_URL + "/" + card.cover ? process.env.NEXT_PUBLIC_COVER_PREVIEW_URL + "/" + card.cover
: undefined, : undefined,
cover_preview: card.cover };
? process.env.NEXT_PUBLIC_COVER_PREVIEW_URL + "/" + card.cover });
: undefined,
};
});
export type GameCardType = z.infer<typeof gameCardSchema>; export type GameCardType = z.infer<typeof gameCardSchema>;
export const isGameCard = (a: any): a is GameCardType => { export const isGameCard = (a: any): a is GameCardType => {
return gameCardSchema.safeParse(a).success; return gameCardSchema.safeParse(a).success;
}; };
export const gameCardsSchema = z.array(z.any()).transform((a) => { export const gameCardsSchema = z.array(z.any()).transform((a) => {
const cards: GameCardType[] = []; const cards: GameCardType[] = [];
a.forEach((e) => { a.forEach((e) => {
if (isGameCard(e)) cards.push(gameCardSchema.parse(e)); if (isGameCard(e)) cards.push(gameCardSchema.parse(e));
else console.error("GameCard parse error - ", e); else console.error("GameCard parse error - ", e);
}); });
return cards; return cards;
}); });

View File

@@ -3,30 +3,30 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
export const GameCard = ({ card }: { card: GameCardType }) => { export const GameCard = ({ card }: { card: GameCardType }) => {
return ( return (
<Link className="group/gamecard cursor-pointer" href={"/games/" + card.id}> <Link className="group/gamecard cursor-pointer" href={"/games/" + card.id}>
{!!card.cover_preview && ( {!!card.cover_preview && (
<Image <Image
src={card.cover_preview} src={card.cover_preview}
className="rounded-lg" className="rounded-lg"
alt="" alt=""
width={700} width={700}
height={400} height={400}
/> />
)} )}
<div className="flex items-center justify-between pr-2"> <div className="flex items-center justify-between pr-2">
<h2 className="text-2xl py-1 group-hover/gamecard:underline underline-offset-4"> <h2 className="text-2xl py-1 group-hover/gamecard:underline underline-offset-4">
{card.title} {card.title}
</h2> </h2>
<span className="text-sm text-fg4"> {card.version && (
{card.release_date.toLocaleDateString("ru-ru", { <span className="text-xs max-w-[30%] line-clamp-2 text-fg4">
year: "numeric", {card.version}
})} </span>
</span> )}
</div> </div>
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4"> <p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
{card.description} {card.description}
</p> </p>
</Link> </Link>
); );
}; };

View File

@@ -1,19 +0,0 @@
import Image from "next/image";
export const Cover = ({
cover,
type = "preview",
}: {
cover: string;
type?: "cover" | "preview";
}) => {
return (
<Image
src={cover}
className="rounded-lg aspect-video object-cover"
alt=""
width={type === "preview" ? 700 : 1280}
height={type === "preview" ? 400 : 720}
/>
);
};

View File

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

View File

@@ -4,59 +4,59 @@ import { MobileMenu } from "./mobileMenu/mobileMenu";
import Link from "next/link"; import Link from "next/link";
const sections = [ const sections = [
{ title: "Игры", href: "/games" }, { title: "Игры", href: "/games" },
{ title: "Фильмы", href: "/films" }, { title: "Фильмы", href: "/films" },
{ title: "Аудиокниги", href: "/audiobooks" }, { title: "Аудиокниги", href: "/audiobooks" },
]; ];
export const Header = () => { export const Header = () => {
return ( return (
<header className="w-full h-20 bg-bg1"> <header className="w-full h-20 bg-bg1 sticky top-0">
<div <div
className="w-full h-full max-w-[var(--app-width)] m-auto px-5 className="w-full h-full max-w-[var(--app-width)] m-auto px-5
flex items-center justify-between" flex items-center justify-between"
> >
<h1 className="text-4xl font-bold flex items-center"> <h1 className="text-4xl font-bold flex items-center">
<div className="lp:hidden"> <div className="lp:hidden">
<MobileMenu sections={sections} /> <MobileMenu sections={sections} />
</div> </div>
<Link href="/">.Torrent</Link> <Link href="/">.Torrent</Link>
</h1> </h1>
<div className="hidden text-2xl dsk:block"> <div className="hidden text-2xl dsk:block">
{sections.map((section) => ( {sections.map((section) => (
<Link <Link
key={section.title} key={section.title}
className="px-5 cursor-pointer hover:underline" className="px-5 cursor-pointer hover:underline"
href={section.href} href={section.href}
> >
{section.title} {section.title}
</Link> </Link>
))} ))}
</div> </div>
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="flex items-center mb-1 "> <span className="flex items-center mb-1 ">
<SchemeSwitch /> <SchemeSwitch />
<span className="cursor-pointer flex items-center"> <span className="cursor-pointer flex items-center">
<PersonIcon className="mr-1 h-4 w-4" /> <PersonIcon className="mr-1 h-4 w-4" />
Войти Войти
</span> </span>
</span> </span>
<label className="flex flex-col items-start relative w-36"> <label className="flex flex-col items-start relative w-36">
<input <input
className="peer/search w-full rounded-lg bg-bg4 px-2" className="peer/search w-full rounded-lg bg-bg4 px-2"
placeholder=" " placeholder=" "
/> />
<span <span
className="peer-focus/search:opacity-0 className="peer-focus/search:opacity-0
peer-[:not(:placeholder-shown)]/search:opacity-0 peer-[:not(:placeholder-shown)]/search:opacity-0
transition-opacity h-0 flex items-center relative bottom-3" transition-opacity h-0 flex items-center relative bottom-3"
> >
<SearchIcon className="w-4 h-4 mx-2" /> <SearchIcon className="w-4 h-4 mx-2" />
Поиск Поиск
</span> </span>
</label> </label>
</div> </div>
</div> </div>
</header> </header>
); );
}; };

View File

@@ -1,34 +1,37 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
const config: Config = { const config: Config = {
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}",
], ],
theme: { theme: {
extend: { extend: {
backgroundImage: { backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))", "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic": "gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
}, },
colors: { colors: {
bg0: "var(--color-bg0)", bg0: "var(--color-bg0)",
bg1: "var(--color-bg1)", bg1: "var(--color-bg1)",
bg4: "var(--color-bg4)", bg4: "var(--color-bg4)",
fg0: "var(--color-fg0)", fg0: "var(--color-fg0)",
fg1: "var(--color-fg1)", fg1: "var(--color-fg1)",
fg4: "var(--color-fg4)", fg4: "var(--color-fg4)",
}, ac0: "var(--color-ac0)",
}, ac1: "var(--color-ac1)",
screens: { ac2: "var(--color-ac2)",
tb: "640px", },
lp: "1024px", },
dsk: "1280px", screens: {
}, tb: "640px",
}, lp: "1024px",
plugins: [], dsk: "1280px",
},
},
plugins: [],
}; };
export default config; export default config;