mirror of
https://github.com/StepanovPlaton/torrent_frontend.git
synced 2026-04-03 12:20:48 +04:00
Add audiobooks
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
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_CONTENT_URL=http://127.0.0.1:8000/content/torrent
|
||||||
|
NEXT_PUBLIC_FRAGMENT_URL=http://127.0.0.1:8000/content/audio
|
||||||
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
|
||||||
45
src/app/[section]/[item_id]/page.tsx
Normal file
45
src/app/[section]/[item_id]/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { GameService, GameType, isSection, ItemService } from "@/entities/item";
|
||||||
|
import { ItemCard } from "@/features/itemCard";
|
||||||
|
import { ItemInfo } from "@/widgets/itemInfo";
|
||||||
|
import { Section } from "@/widgets/section";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function Item({
|
||||||
|
params: { section, item_id },
|
||||||
|
}: {
|
||||||
|
params: { section: string; item_id: number };
|
||||||
|
}) {
|
||||||
|
const game = isSection(section)
|
||||||
|
? await ItemService.itemSections[section].service.Get(item_id)
|
||||||
|
: redirect("/");
|
||||||
|
|
||||||
|
const cards =
|
||||||
|
isSection(section) &&
|
||||||
|
(await ItemService.itemSections[section].service.GetCards());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{game && <ItemInfo item={game} />}
|
||||||
|
|
||||||
|
{cards && (
|
||||||
|
<Section
|
||||||
|
name={
|
||||||
|
isSection(section)
|
||||||
|
? ItemService.itemSections[section].popularSubsectionName
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
link={isSection(section) ? `/${section}` : undefined}
|
||||||
|
invite_text={
|
||||||
|
isSection(section)
|
||||||
|
? ItemService.itemSections[section].sectionInviteText
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<ItemCard key={card.id} card={card} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/app/[section]/add/page.tsx
Normal file
45
src/app/[section]/add/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { GameService, isSection, ItemService } from "@/entities/item";
|
||||||
|
import { ItemCard } from "@/features/itemCard";
|
||||||
|
import { ItemInfo } from "@/widgets/itemInfo";
|
||||||
|
import { Section } from "@/widgets/section";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function AddItem({
|
||||||
|
params: { section },
|
||||||
|
}: {
|
||||||
|
params: { section: string };
|
||||||
|
}) {
|
||||||
|
const emptyItem = isSection(section)
|
||||||
|
? await ItemService.itemSections[section].service.GetEmpty()
|
||||||
|
: redirect("/");
|
||||||
|
|
||||||
|
const cards =
|
||||||
|
isSection(section) &&
|
||||||
|
(await ItemService.itemSections[section].service.GetCards());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ItemInfo item={emptyItem} />
|
||||||
|
|
||||||
|
{cards && (
|
||||||
|
<Section
|
||||||
|
name={
|
||||||
|
isSection(section)
|
||||||
|
? ItemService.itemSections[section].popularSubsectionName
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
link={isSection(section) ? `/${section}` : undefined}
|
||||||
|
invite_text={
|
||||||
|
isSection(section)
|
||||||
|
? ItemService.itemSections[section].sectionInviteText
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<ItemCard key={card.id} card={card} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/app/[section]/page.tsx
Normal file
31
src/app/[section]/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { isSection, ItemService, MovieService } from "@/entities/item";
|
||||||
|
import { ItemCard } from "@/features/itemCard";
|
||||||
|
import { Section } from "@/widgets/section";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
//export const metadata: Metadata = {
|
||||||
|
// title: ".Torrent: Фильмы",
|
||||||
|
// description: ".Torrent: Фильмы - каталог .torrent файлов для обмена фильмами",
|
||||||
|
//};
|
||||||
|
|
||||||
|
export default async function SectionPage({
|
||||||
|
params: { section },
|
||||||
|
}: {
|
||||||
|
params: { section: string };
|
||||||
|
}) {
|
||||||
|
const cards = isSection(section)
|
||||||
|
? await ItemService.itemSections[section].service.GetCards()
|
||||||
|
: redirect("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cards && cards.length > 0 && (
|
||||||
|
<Section>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<ItemCard key={card.id} card={card} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { GameService } from "@/entities/game";
|
|
||||||
import { GameCard } from "@/features/gameCard";
|
|
||||||
import { GameInfo } from "@/widgets/gameInfo";
|
|
||||||
import { Section } from "@/widgets/section";
|
|
||||||
|
|
||||||
export default async function Games({
|
|
||||||
params: { game_id },
|
|
||||||
}: {
|
|
||||||
params: { game_id: number };
|
|
||||||
}) {
|
|
||||||
const gameCards = await GameService.GetGameCards();
|
|
||||||
const game = await GameService.GetGame(game_id);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{game && <GameInfo game={game} />}
|
|
||||||
|
|
||||||
{gameCards && (
|
|
||||||
<Section
|
|
||||||
name="Популярные игры"
|
|
||||||
link="/games"
|
|
||||||
invite_text={'Перейти в раздел "Игры"'}
|
|
||||||
>
|
|
||||||
{gameCards.map((card) => (
|
|
||||||
<GameCard key={card.id} card={card} />
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { GameService } from "@/entities/game";
|
|
||||||
import { GameCard } from "@/features/gameCard";
|
|
||||||
import { GameInfo } from "@/widgets/gameInfo";
|
|
||||||
import { Section } from "@/widgets/section";
|
|
||||||
|
|
||||||
export default async function AddGame() {
|
|
||||||
const gameCards = await GameService.GetGameCards();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GameInfo game={GameService.GetEmptyGame()} />
|
|
||||||
|
|
||||||
{gameCards && (
|
|
||||||
<Section
|
|
||||||
name="Популярные игры"
|
|
||||||
link="/games"
|
|
||||||
invite_text={'Перейти в раздел "Игры"'}
|
|
||||||
>
|
|
||||||
{gameCards.map((card) => (
|
|
||||||
<GameCard key={card.id} card={card} />
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { GameService } from "@/entities/game";
|
|
||||||
import { GameCard } from "@/features/gameCard";
|
|
||||||
import { Section } from "@/widgets/section";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: ".Torrent: Игры",
|
|
||||||
description:
|
|
||||||
".Torrent: Игры - каталог .torrent файлов для обмена видеоиграми",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function Games() {
|
|
||||||
const gameCards = await GameService.GetGameCards();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{gameCards && gameCards.length > 0 && (
|
|
||||||
<Section>
|
|
||||||
{gameCards.map((card) => (
|
|
||||||
<GameCard key={card.id} card={card} />
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -52,8 +52,6 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--color-fg1);
|
color: var(--color-fg1);
|
||||||
background-color: var(--color-bg0);
|
background-color: var(--color-bg0);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body * {
|
body * {
|
||||||
@@ -61,6 +59,10 @@ body * {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audio::-webkit-media-controls-panel {
|
||||||
|
background-color: var(--color-bg1);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
:root {
|
:root {
|
||||||
font-size: calc((100vw / 1920) * 56);
|
font-size: calc((100vw / 1920) * 56);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { GameService } from "@/entities/game";
|
import {
|
||||||
import { GameCard } from "@/features/gameCard";
|
isSection,
|
||||||
|
ItemCardType,
|
||||||
|
ItemSections,
|
||||||
|
ItemSectionsType,
|
||||||
|
ItemService,
|
||||||
|
} from "@/entities/item";
|
||||||
|
import { ItemCard } from "@/features/itemCard";
|
||||||
import { Section } from "@/widgets/section";
|
import { Section } from "@/widgets/section";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
|
||||||
@@ -10,20 +16,32 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const gameCards = await GameService.GetGameCards();
|
const cards: { [k in ItemSectionsType]?: ItemCardType[] | null } = {};
|
||||||
|
await Promise.all(
|
||||||
|
ItemSections.map(async (section) => {
|
||||||
|
cards[section] = await ItemService.itemSections[
|
||||||
|
section
|
||||||
|
].service.GetCards();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{gameCards && gameCards.length > 0 && (
|
{ItemSections.map((section) => (
|
||||||
<Section
|
<section key={section}>
|
||||||
name="Игры"
|
{cards[section] && cards[section].length > 0 && (
|
||||||
link="/games"
|
<Section
|
||||||
invite_text={'Перейти в раздел "Игры"'}
|
name={ItemService.itemSections[section].popularSubsectionName}
|
||||||
>
|
link={isSection(section) ? `/${section}` : undefined}
|
||||||
{gameCards.map((card) => (
|
invite_text={ItemService.itemSections[section].sectionInviteText}
|
||||||
<GameCard key={card.id} card={card} />
|
>
|
||||||
))}
|
{cards[section].map((card) => (
|
||||||
</Section>
|
<ItemCard key={card.id} card={card} />
|
||||||
)}
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { HTTPService } from "@/shared/utils/http";
|
import { HTTPService } from "@/shared/utils/http";
|
||||||
import { coverNameSchema } from "./schemas/cover";
|
import { coverNameSchema } from "./schemas/cover";
|
||||||
|
import { torrentNameSchema } from "./schemas/torrent";
|
||||||
|
import { fragmentNameSchema } from "./schemas/fragment";
|
||||||
|
|
||||||
export abstract class FilesService {
|
export abstract class FilesService {
|
||||||
public static async UploadCover(cover: File) {
|
public static async UploadCover(cover: File) {
|
||||||
@@ -19,7 +21,19 @@ export abstract class FilesService {
|
|||||||
formData.append("torrent", torrent);
|
formData.append("torrent", torrent);
|
||||||
return await HTTPService.post(
|
return await HTTPService.post(
|
||||||
`/files/torrent`,
|
`/files/torrent`,
|
||||||
coverNameSchema,
|
torrentNameSchema,
|
||||||
|
formData,
|
||||||
|
{},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async UploadFragment(fragment: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("fragment", fragment);
|
||||||
|
return await HTTPService.post(
|
||||||
|
`/files/audio`,
|
||||||
|
fragmentNameSchema,
|
||||||
formData,
|
formData,
|
||||||
{},
|
{},
|
||||||
false
|
false
|
||||||
|
|||||||
4
src/entities/files/schemas/fragment.ts
Normal file
4
src/entities/files/schemas/fragment.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const fragmentNameSchema = z.string().min(5);
|
||||||
|
export type FragmentNameType = z.infer<typeof fragmentNameSchema>;
|
||||||
4
src/entities/files/schemas/torrent.ts
Normal file
4
src/entities/files/schemas/torrent.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const torrentNameSchema = z.string().min(5);
|
||||||
|
export type TorrentNameType = z.infer<typeof torrentNameSchema>;
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { HTTPService } from "@/shared/utils/http";
|
|
||||||
import { gameCardsSchema } from "./schemas/gameCard";
|
|
||||||
import { GameCreateType, gameSchema } from "./schemas/game";
|
|
||||||
|
|
||||||
export abstract class GameService {
|
|
||||||
public static async GetGameCards() {
|
|
||||||
return await HTTPService.get("/games/cards", gameCardsSchema);
|
|
||||||
}
|
|
||||||
public static async GetGame(id: number) {
|
|
||||||
return await HTTPService.get(`/games/${id}`, gameSchema);
|
|
||||||
}
|
|
||||||
public static async ChangeGame(id: number, gameInfo: GameCreateType) {
|
|
||||||
return await HTTPService.put(`/games/${id}`, gameSchema, gameInfo);
|
|
||||||
}
|
|
||||||
public static async AddGame(gameInfo: GameCreateType) {
|
|
||||||
return await HTTPService.post(`/games`, gameSchema, gameInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static GetEmptyGame(): GameCreateType {
|
|
||||||
return {
|
|
||||||
title: "",
|
|
||||||
torrent_file: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { GameCardType } from "./schemas/gameCard";
|
|
||||||
import { GameType, GameCreateType, gameCreateSchema } from "./schemas/game";
|
|
||||||
import { GameService } from "./game";
|
|
||||||
|
|
||||||
export {
|
|
||||||
GameService,
|
|
||||||
gameCreateSchema,
|
|
||||||
type GameType,
|
|
||||||
type GameCreateType,
|
|
||||||
type GameCardType,
|
|
||||||
};
|
|
||||||
66
src/entities/item/audiobook/audiobook.ts
Normal file
66
src/entities/item/audiobook/audiobook.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { HTTPService } from "@/shared/utils/http";
|
||||||
|
import { audiobookCardsSchema } from "./schemas/audiobookCard";
|
||||||
|
import {
|
||||||
|
AudiobookCreateType,
|
||||||
|
audiobookSchema,
|
||||||
|
AudiobookType,
|
||||||
|
} from "./schemas/audiobook";
|
||||||
|
import {
|
||||||
|
IItemService,
|
||||||
|
ItemCreateType,
|
||||||
|
ItemPropertiesDescriptionType,
|
||||||
|
ItemType,
|
||||||
|
staticImplements,
|
||||||
|
TypesOfItems,
|
||||||
|
} from "../types";
|
||||||
|
import { ItemService } from "../item";
|
||||||
|
|
||||||
|
@staticImplements<IItemService>()
|
||||||
|
export abstract class AudiobookService {
|
||||||
|
public static async GetCards() {
|
||||||
|
return await HTTPService.get("/audiobooks/cards", audiobookCardsSchema);
|
||||||
|
}
|
||||||
|
public static async Get(id: number) {
|
||||||
|
return await HTTPService.get(`/audiobooks/${id}`, audiobookSchema);
|
||||||
|
}
|
||||||
|
public static async Add(info: AudiobookCreateType) {
|
||||||
|
return await HTTPService.post(`/audiobooks`, audiobookSchema, info);
|
||||||
|
}
|
||||||
|
public static async Change(id: number, info: AudiobookCreateType) {
|
||||||
|
return await HTTPService.put(`/audiobooks/${id}`, audiobookSchema, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GetEmpty(): AudiobookCreateType {
|
||||||
|
return {
|
||||||
|
title: "",
|
||||||
|
torrent_file: "",
|
||||||
|
type: TypesOfItems.audiobook,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static propertiesDescription: ItemPropertiesDescriptionType<AudiobookType> = [
|
||||||
|
[
|
||||||
|
{ name: "Автор", key: "author" },
|
||||||
|
{ name: "Язык", key: "language" },
|
||||||
|
{ name: "Читает", key: "reader" },
|
||||||
|
{ name: "Продолжительность", key: "duration" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "Дата обновления раздачи",
|
||||||
|
key: "update_date",
|
||||||
|
value: (item: ItemType | ItemCreateType) => {
|
||||||
|
return ItemService.isExistingItem(item)
|
||||||
|
? item.update_date.toLocaleDateString("ru-ru")
|
||||||
|
: new Date().toLocaleDateString("ru-ru");
|
||||||
|
},
|
||||||
|
editable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Год выхода",
|
||||||
|
key: "release_date",
|
||||||
|
},
|
||||||
|
{ name: "Объём загрузки", key: "download_size" },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
58
src/entities/item/audiobook/schemas/audiobook.ts
Normal file
58
src/entities/item/audiobook/schemas/audiobook.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { audiobookCardBaseSchema } from "./audiobookCard";
|
||||||
|
|
||||||
|
export const audiobookBaseSchema = audiobookCardBaseSchema.merge(
|
||||||
|
z.object({
|
||||||
|
torrent_file: z.string().min(3, "У раздачи должен быть .torrent файл"),
|
||||||
|
fragment: z.string().optional().nullable(),
|
||||||
|
|
||||||
|
language: z.string().optional().nullable(),
|
||||||
|
download_size: z.string().optional().nullable(),
|
||||||
|
duration: z.string().optional().nullable(),
|
||||||
|
reader: z.string().optional().nullable(),
|
||||||
|
|
||||||
|
release_date: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.transform((d) =>
|
||||||
|
d
|
||||||
|
? new Date(d).toLocaleDateString("en-us", {
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const audiobookCreateSchema = audiobookBaseSchema.merge(z.object({}));
|
||||||
|
export type AudiobookCreateType = z.infer<typeof audiobookCreateSchema>;
|
||||||
|
|
||||||
|
export const audiobookSchema = audiobookBaseSchema.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 AudiobookType = z.infer<typeof audiobookSchema>;
|
||||||
|
|
||||||
|
export const isAudiobook = (a: any): a is AudiobookType => {
|
||||||
|
return audiobookSchema.safeParse(a).success;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const audiobooksSchema = z.array(z.any()).transform((a) => {
|
||||||
|
const audiobooks: AudiobookType[] = [];
|
||||||
|
a.forEach((e) => {
|
||||||
|
if (isAudiobook(e)) audiobooks.push(audiobookSchema.parse(e));
|
||||||
|
else console.error("Audiobook parse error - ", e);
|
||||||
|
});
|
||||||
|
return audiobooks;
|
||||||
|
});
|
||||||
43
src/entities/item/audiobook/schemas/audiobookCard.ts
Normal file
43
src/entities/item/audiobook/schemas/audiobookCard.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { TypesOfItems } from "../../types";
|
||||||
|
|
||||||
|
export const audiobookCardBaseSchema = z.object({
|
||||||
|
title: z.string().min(3, "Слишком короткое название"),
|
||||||
|
cover: z.string().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
author: z.string().optional().nullable(),
|
||||||
|
|
||||||
|
// Добавляем к каждой аудиокниге поле, которое
|
||||||
|
// показывает, что item является аудиокнигой
|
||||||
|
type: z
|
||||||
|
.any()
|
||||||
|
.optional()
|
||||||
|
.transform(() => TypesOfItems.audiobook),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const audiobookCardSchema = audiobookCardBaseSchema.merge(
|
||||||
|
z.object({
|
||||||
|
id: z.number().positive(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
export type AudiobookCardType = z.infer<typeof audiobookCardSchema>;
|
||||||
|
|
||||||
|
export const isAudiobookCardStrict = (a: any): a is AudiobookCardType => {
|
||||||
|
return audiobookCardSchema.safeParse(a).success;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const audiobookCardsSchema = z.array(z.any()).transform((a) => {
|
||||||
|
const cards: AudiobookCardType[] = [];
|
||||||
|
a.forEach((e) => {
|
||||||
|
if (isAudiobookCardStrict(e)) cards.push(audiobookCardSchema.parse(e));
|
||||||
|
else console.error("AudiobookCard parse error - ", e);
|
||||||
|
});
|
||||||
|
return cards;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isAudiobook = (a: any): a is AudiobookCardType => {
|
||||||
|
return (
|
||||||
|
audiobookCardBaseSchema.safeParse(a).success &&
|
||||||
|
(a as AudiobookCardType).type === TypesOfItems.audiobook
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/entities/item/game/game.ts
Normal file
66
src/entities/item/game/game.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { HTTPService } from "@/shared/utils/http";
|
||||||
|
import { gameCardsSchema } from "./schemas/gameCard";
|
||||||
|
import { GameCreateType, gameSchema, GameType } from "./schemas/game";
|
||||||
|
import {
|
||||||
|
IItemService,
|
||||||
|
ItemCreateType,
|
||||||
|
ItemPropertiesDescriptionType,
|
||||||
|
ItemType,
|
||||||
|
staticImplements,
|
||||||
|
TypesOfItems,
|
||||||
|
} from "../types";
|
||||||
|
import { ItemService } from "../item";
|
||||||
|
|
||||||
|
@staticImplements<IItemService>()
|
||||||
|
export abstract class GameService {
|
||||||
|
public static async GetCards() {
|
||||||
|
return await HTTPService.get("/games/cards", gameCardsSchema);
|
||||||
|
}
|
||||||
|
public static async Get(id: number) {
|
||||||
|
return await HTTPService.get(`/games/${id}`, gameSchema);
|
||||||
|
}
|
||||||
|
public static async Add(info: GameCreateType) {
|
||||||
|
return await HTTPService.post(`/games`, gameSchema, info);
|
||||||
|
}
|
||||||
|
public static async Change(id: number, info: GameCreateType) {
|
||||||
|
return await HTTPService.put(`/games/${id}`, gameSchema, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GetEmpty(): GameCreateType {
|
||||||
|
return {
|
||||||
|
title: "",
|
||||||
|
torrent_file: "",
|
||||||
|
type: TypesOfItems.game,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static propertiesDescription: ItemPropertiesDescriptionType<GameType> = [
|
||||||
|
[
|
||||||
|
{ name: "Система", key: "system" },
|
||||||
|
{ name: "Процессор", key: "processor" },
|
||||||
|
{ name: "Оперативная память", key: "memory" },
|
||||||
|
{ name: "Видеокарта", key: "graphics" },
|
||||||
|
{ name: "Место на диске", key: "storage" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ name: "Версия игры", key: "version" },
|
||||||
|
{
|
||||||
|
name: "Дата обновления раздачи",
|
||||||
|
key: "update_date",
|
||||||
|
value: (item: ItemType | ItemCreateType) => {
|
||||||
|
return ItemService.isExistingItem(item)
|
||||||
|
? item.update_date.toLocaleDateString("ru-ru")
|
||||||
|
: new Date().toLocaleDateString("ru-ru");
|
||||||
|
},
|
||||||
|
editable: false,
|
||||||
|
},
|
||||||
|
{ name: "Язык", key: "language" },
|
||||||
|
{ name: "Разработчик", key: "developer" },
|
||||||
|
{
|
||||||
|
name: "Год выхода",
|
||||||
|
key: "release_date",
|
||||||
|
},
|
||||||
|
{ name: "Объём загрузки", key: "download_size" },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TypesOfItems } from "../../types";
|
||||||
|
|
||||||
export const gameCardBaseSchema = z.object({
|
export const gameCardBaseSchema = z.object({
|
||||||
title: z.string().min(3, "Слишком короткое название"),
|
title: z.string().min(3, "Слишком короткое название"),
|
||||||
cover: z.string().optional().nullable(),
|
cover: z.string().optional().nullable(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
version: z.string().optional().nullable(),
|
version: z.string().optional().nullable(),
|
||||||
|
|
||||||
|
// Добавляем к каждой игре поле, которое
|
||||||
|
// показывает, что item является игрой
|
||||||
|
type: z
|
||||||
|
.any()
|
||||||
|
.optional()
|
||||||
|
.transform(() => TypesOfItems.game),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const gameCardSchema = gameCardBaseSchema.merge(
|
export const gameCardSchema = gameCardBaseSchema.merge(
|
||||||
@@ -14,15 +22,22 @@ export const gameCardSchema = gameCardBaseSchema.merge(
|
|||||||
);
|
);
|
||||||
export type GameCardType = z.infer<typeof gameCardSchema>;
|
export type GameCardType = z.infer<typeof gameCardSchema>;
|
||||||
|
|
||||||
export const isGameCard = (a: any): a is GameCardType => {
|
export const isGameCardStrict = (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 (isGameCardStrict(e)) cards.push(gameCardSchema.parse(e));
|
||||||
else console.error("GameCard parse error - ", e);
|
else console.error("GameCard parse error - ", e);
|
||||||
});
|
});
|
||||||
return cards;
|
return cards;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const isGame = (a: any): a is GameCardType => {
|
||||||
|
return (
|
||||||
|
gameCardBaseSchema.safeParse(a).success &&
|
||||||
|
(a as GameCardType).type === TypesOfItems.game
|
||||||
|
);
|
||||||
|
};
|
||||||
104
src/entities/item/index.ts
Normal file
104
src/entities/item/index.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
gameSchema,
|
||||||
|
gamesSchema,
|
||||||
|
gameCreateSchema,
|
||||||
|
type GameType,
|
||||||
|
type GameCreateType,
|
||||||
|
} from "./game/schemas/game";
|
||||||
|
export {
|
||||||
|
gameSchema,
|
||||||
|
gamesSchema,
|
||||||
|
gameCreateSchema,
|
||||||
|
type GameType,
|
||||||
|
type GameCreateType,
|
||||||
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
|
gameCardSchema,
|
||||||
|
gameCardsSchema,
|
||||||
|
type GameCardType,
|
||||||
|
isGame,
|
||||||
|
} from "./game/schemas/gameCard";
|
||||||
|
export { gameCardSchema, gameCardsSchema, type GameCardType, isGame };
|
||||||
|
|
||||||
|
import { GameService } from "./game/game";
|
||||||
|
export { GameService };
|
||||||
|
|
||||||
|
import {
|
||||||
|
movieSchema,
|
||||||
|
moviesSchema,
|
||||||
|
movieCreateSchema,
|
||||||
|
type MovieType,
|
||||||
|
type MovieCreateType,
|
||||||
|
} from "./movie/schemas/movie";
|
||||||
|
export {
|
||||||
|
movieSchema,
|
||||||
|
moviesSchema,
|
||||||
|
movieCreateSchema,
|
||||||
|
type MovieType,
|
||||||
|
type MovieCreateType,
|
||||||
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
|
movieCardSchema,
|
||||||
|
movieCardsSchema,
|
||||||
|
type MovieCardType,
|
||||||
|
isMovie,
|
||||||
|
} from "./movie/schemas/movieCard";
|
||||||
|
export { movieCardSchema, movieCardsSchema, type MovieCardType, isMovie };
|
||||||
|
|
||||||
|
import { MovieService } from "./movie/movie";
|
||||||
|
export { MovieService };
|
||||||
|
|
||||||
|
import {
|
||||||
|
audiobookSchema,
|
||||||
|
audiobooksSchema,
|
||||||
|
audiobookCreateSchema,
|
||||||
|
type AudiobookType,
|
||||||
|
type AudiobookCreateType,
|
||||||
|
} from "./audiobook/schemas/audiobook";
|
||||||
|
export {
|
||||||
|
audiobookSchema,
|
||||||
|
audiobooksSchema,
|
||||||
|
audiobookCreateSchema,
|
||||||
|
type AudiobookType,
|
||||||
|
type AudiobookCreateType,
|
||||||
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
|
audiobookCardSchema,
|
||||||
|
audiobookCardsSchema,
|
||||||
|
type AudiobookCardType,
|
||||||
|
isAudiobook,
|
||||||
|
} from "./audiobook/schemas/audiobookCard";
|
||||||
|
export {
|
||||||
|
audiobookCardSchema,
|
||||||
|
audiobookCardsSchema,
|
||||||
|
type AudiobookCardType,
|
||||||
|
isAudiobook,
|
||||||
|
};
|
||||||
|
|
||||||
|
import { AudiobookService } from "./audiobook/audiobook";
|
||||||
|
export { AudiobookService };
|
||||||
|
|
||||||
|
import { ItemService } from "./item";
|
||||||
|
export { ItemService };
|
||||||
|
|
||||||
|
import {
|
||||||
|
isSection,
|
||||||
|
type ItemType,
|
||||||
|
type ItemCardType,
|
||||||
|
type ItemCreateType,
|
||||||
|
type TypesOfItems,
|
||||||
|
type ItemSectionsType,
|
||||||
|
ItemSections,
|
||||||
|
} from "./types";
|
||||||
|
export {
|
||||||
|
isSection,
|
||||||
|
type ItemType,
|
||||||
|
type ItemCardType,
|
||||||
|
type ItemCreateType,
|
||||||
|
type TypesOfItems,
|
||||||
|
type ItemSectionsType,
|
||||||
|
ItemSections,
|
||||||
|
};
|
||||||
131
src/entities/item/item.ts
Normal file
131
src/entities/item/item.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { ZodSchema } from "zod";
|
||||||
|
import { gameCreateSchema, gameSchema } from "./game/schemas/game";
|
||||||
|
import { movieCreateSchema, movieSchema } from "./movie/schemas/movie";
|
||||||
|
import { MovieService } from "./movie/movie";
|
||||||
|
import { HTTPService } from "@/shared/utils/http";
|
||||||
|
import { GameService } from "./game/game";
|
||||||
|
import {
|
||||||
|
audiobookCreateSchema,
|
||||||
|
audiobookSchema,
|
||||||
|
} from "./audiobook/schemas/audiobook";
|
||||||
|
import { AudiobookService } from "./audiobook/audiobook";
|
||||||
|
import {
|
||||||
|
IItemService,
|
||||||
|
ItemCardType,
|
||||||
|
ItemCreateType,
|
||||||
|
ItemPropertiesDescriptionType,
|
||||||
|
ItemSectionsType,
|
||||||
|
ItemType,
|
||||||
|
TypesOfItems,
|
||||||
|
UnionItemType,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export abstract class ItemService {
|
||||||
|
private static get itemsConfiguration(): {
|
||||||
|
[k in TypesOfItems]: {
|
||||||
|
sectionUrl: ItemSectionsType;
|
||||||
|
formResolver: ZodSchema;
|
||||||
|
propertiesDescription: ItemPropertiesDescriptionType<UnionItemType>;
|
||||||
|
AddItem: (itemInfo: ItemCreateType) => Promise<ItemType | null>;
|
||||||
|
ChangeItem: (
|
||||||
|
id: number,
|
||||||
|
itemInfo: ItemCreateType
|
||||||
|
) => Promise<ItemType | null>;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
[TypesOfItems.game]: {
|
||||||
|
sectionUrl: "games",
|
||||||
|
formResolver: gameCreateSchema,
|
||||||
|
propertiesDescription: GameService.propertiesDescription,
|
||||||
|
AddItem: async (itemInfo) =>
|
||||||
|
await HTTPService.post(`/games`, gameSchema, itemInfo),
|
||||||
|
ChangeItem: async (id: number, itemInfo) =>
|
||||||
|
await HTTPService.put(`/games/${id}`, gameSchema, itemInfo),
|
||||||
|
},
|
||||||
|
[TypesOfItems.movie]: {
|
||||||
|
sectionUrl: "movies",
|
||||||
|
formResolver: movieCreateSchema,
|
||||||
|
propertiesDescription: MovieService.propertiesDescription,
|
||||||
|
AddItem: async (itemInfo) =>
|
||||||
|
await HTTPService.post(`/movies`, movieSchema, itemInfo),
|
||||||
|
ChangeItem: async (id: number, itemInfo) =>
|
||||||
|
await HTTPService.put(`/movies/${id}`, movieSchema, itemInfo),
|
||||||
|
},
|
||||||
|
[TypesOfItems.audiobook]: {
|
||||||
|
sectionUrl: "audiobooks",
|
||||||
|
formResolver: audiobookCreateSchema,
|
||||||
|
propertiesDescription: AudiobookService.propertiesDescription,
|
||||||
|
AddItem: async (itemInfo) =>
|
||||||
|
await HTTPService.post(`/audiobooks`, audiobookSchema, itemInfo),
|
||||||
|
ChangeItem: async (id: number, itemInfo) =>
|
||||||
|
await HTTPService.put(`/audiobooks/${id}`, audiobookSchema, itemInfo),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get itemSections(): {
|
||||||
|
[k in ItemSectionsType]: {
|
||||||
|
itemType: TypesOfItems;
|
||||||
|
popularSubsectionName: string;
|
||||||
|
sectionInviteText: string;
|
||||||
|
service: IItemService;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
games: {
|
||||||
|
itemType: TypesOfItems.game,
|
||||||
|
popularSubsectionName: "Популярные игры",
|
||||||
|
sectionInviteText: 'Перейти в раздел "Игры"',
|
||||||
|
service: GameService,
|
||||||
|
},
|
||||||
|
movies: {
|
||||||
|
itemType: TypesOfItems.movie,
|
||||||
|
popularSubsectionName: "Популярные фильмы",
|
||||||
|
sectionInviteText: 'Перейти в раздел "Фильмы"',
|
||||||
|
service: MovieService,
|
||||||
|
},
|
||||||
|
audiobooks: {
|
||||||
|
itemType: TypesOfItems.audiobook,
|
||||||
|
popularSubsectionName: "Популярные аудиокниги",
|
||||||
|
sectionInviteText: 'Перейти в раздел "Аудиокниги"',
|
||||||
|
service: AudiobookService,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isExistingItem(
|
||||||
|
item: ItemCreateType | ItemType
|
||||||
|
): item is ItemType {
|
||||||
|
return (item as ItemType).id !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GetFormResolver(
|
||||||
|
item: ItemCardType | ItemCreateType | ItemType
|
||||||
|
) {
|
||||||
|
return this.itemsConfiguration[item.type].formResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GetSectionUrlByItemType(
|
||||||
|
item: ItemCardType | ItemCreateType | ItemType
|
||||||
|
) {
|
||||||
|
return this.itemsConfiguration[item.type].sectionUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GetPropertiesDescriptionForItem<
|
||||||
|
T extends ItemType | ItemCreateType
|
||||||
|
>(item: T) {
|
||||||
|
return this.itemsConfiguration[item.type]
|
||||||
|
.propertiesDescription as ItemPropertiesDescriptionType<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async AddItem(itemInfo: ItemCreateType) {
|
||||||
|
return await this.itemsConfiguration[itemInfo.type].AddItem(itemInfo);
|
||||||
|
}
|
||||||
|
public static async ChangeItem(id: number, itemInfo: ItemCreateType) {
|
||||||
|
return await this.itemsConfiguration[itemInfo.type].ChangeItem(
|
||||||
|
id,
|
||||||
|
itemInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/entities/item/movie/movie.ts
Normal file
65
src/entities/item/movie/movie.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { HTTPService } from "@/shared/utils/http";
|
||||||
|
import { movieCardsSchema } from "./schemas/movieCard";
|
||||||
|
import { MovieCreateType, movieSchema, MovieType } from "./schemas/movie";
|
||||||
|
import {
|
||||||
|
IItemService,
|
||||||
|
ItemCreateType,
|
||||||
|
ItemPropertiesDescriptionType,
|
||||||
|
ItemType,
|
||||||
|
staticImplements,
|
||||||
|
TypesOfItems,
|
||||||
|
} from "../types";
|
||||||
|
import { ItemService } from "../item";
|
||||||
|
|
||||||
|
@staticImplements<IItemService>()
|
||||||
|
export abstract class MovieService {
|
||||||
|
public static async GetCards() {
|
||||||
|
return await HTTPService.get("/movies/cards", movieCardsSchema);
|
||||||
|
}
|
||||||
|
public static async Get(id: number) {
|
||||||
|
return await HTTPService.get(`/movies/${id}`, movieSchema);
|
||||||
|
}
|
||||||
|
public static async Add(info: MovieCreateType) {
|
||||||
|
return await HTTPService.post(`/movies`, movieSchema, info);
|
||||||
|
}
|
||||||
|
public static async Change(id: number, info: MovieCreateType) {
|
||||||
|
return await HTTPService.put(`/movies/${id}`, movieSchema, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GetEmpty(): MovieCreateType {
|
||||||
|
return {
|
||||||
|
title: "",
|
||||||
|
torrent_file: "",
|
||||||
|
type: TypesOfItems.movie,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static propertiesDescription: ItemPropertiesDescriptionType<MovieType> =
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{ name: "Возраст", key: "age" },
|
||||||
|
{ name: "Язык", key: "language" },
|
||||||
|
{ name: "Субтитры", key: "subtitles" },
|
||||||
|
{
|
||||||
|
name: "Год выхода",
|
||||||
|
key: "release_date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "Дата обновления раздачи",
|
||||||
|
key: "update_date",
|
||||||
|
value: (item: ItemType | ItemCreateType) => {
|
||||||
|
return ItemService.isExistingItem(item)
|
||||||
|
? item.update_date.toLocaleDateString("ru-ru")
|
||||||
|
: new Date().toLocaleDateString("ru-ru");
|
||||||
|
},
|
||||||
|
editable: false,
|
||||||
|
},
|
||||||
|
{ name: "Режисёр", key: "director" },
|
||||||
|
{ name: "Продолжительность", key: "duration" },
|
||||||
|
{ name: "Страна", key: "country" },
|
||||||
|
{ name: "Объём загрузки", key: "download_size" },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
60
src/entities/item/movie/schemas/movie.ts
Normal file
60
src/entities/item/movie/schemas/movie.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { movieCardBaseSchema } from "./movieCard";
|
||||||
|
|
||||||
|
export const movieBaseSchema = movieCardBaseSchema.merge(
|
||||||
|
z.object({
|
||||||
|
torrent_file: z.string().min(3, "У раздачи должен быть .torrent файл"),
|
||||||
|
trailer: z.string().optional(),
|
||||||
|
|
||||||
|
language: z.string().optional().nullable(),
|
||||||
|
subtitles: z.string().optional().nullable(),
|
||||||
|
download_size: z.string().optional().nullable(),
|
||||||
|
director: z.string().optional().nullable(),
|
||||||
|
duration: z.string().optional().nullable(),
|
||||||
|
country: z.string().optional().nullable(),
|
||||||
|
|
||||||
|
release_date: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.transform((d) =>
|
||||||
|
d
|
||||||
|
? new Date(d).toLocaleDateString("en-us", {
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const movieCreateSchema = movieBaseSchema.merge(z.object({}));
|
||||||
|
export type MovieCreateType = z.infer<typeof movieCreateSchema>;
|
||||||
|
|
||||||
|
export const movieSchema = movieBaseSchema.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 MovieType = z.infer<typeof movieSchema>;
|
||||||
|
|
||||||
|
export const isMovie = (a: any): a is MovieType => {
|
||||||
|
return movieSchema.safeParse(a).success;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const moviesSchema = z.array(z.any()).transform((a) => {
|
||||||
|
const games: MovieType[] = [];
|
||||||
|
a.forEach((e) => {
|
||||||
|
if (isMovie(e)) games.push(movieSchema.parse(e));
|
||||||
|
else console.error("Movie parse error - ", e);
|
||||||
|
});
|
||||||
|
return games;
|
||||||
|
});
|
||||||
43
src/entities/item/movie/schemas/movieCard.ts
Normal file
43
src/entities/item/movie/schemas/movieCard.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { TypesOfItems } from "../../types";
|
||||||
|
|
||||||
|
export const movieCardBaseSchema = z.object({
|
||||||
|
title: z.string().min(3, "Слишком короткое название"),
|
||||||
|
cover: z.string().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
age: z.string().optional().nullable(),
|
||||||
|
|
||||||
|
// Добавляем к каждому фильму поле, которое
|
||||||
|
// показывает, что item является фильмом
|
||||||
|
type: z
|
||||||
|
.any()
|
||||||
|
.optional()
|
||||||
|
.transform(() => TypesOfItems.movie),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const movieCardSchema = movieCardBaseSchema.merge(
|
||||||
|
z.object({
|
||||||
|
id: z.number().positive(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
export type MovieCardType = z.infer<typeof movieCardSchema>;
|
||||||
|
|
||||||
|
export const isMovieCardStrict = (a: any): a is MovieCardType => {
|
||||||
|
return movieCardSchema.safeParse(a).success;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const movieCardsSchema = z.array(z.any()).transform((a) => {
|
||||||
|
const cards: MovieCardType[] = [];
|
||||||
|
a.forEach((e) => {
|
||||||
|
if (isMovieCardStrict(e)) cards.push(movieCardSchema.parse(e));
|
||||||
|
else console.error("MovieCard parse error - ", e);
|
||||||
|
});
|
||||||
|
return cards;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isMovie = (a: any): a is MovieCardType => {
|
||||||
|
return (
|
||||||
|
movieCardBaseSchema.safeParse(a).success &&
|
||||||
|
(a as MovieCardType).type === TypesOfItems.movie
|
||||||
|
);
|
||||||
|
};
|
||||||
62
src/entities/item/types.ts
Normal file
62
src/entities/item/types.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { GameCreateType, GameType } from "./game/schemas/game";
|
||||||
|
import { GameCardType } from "./game/schemas/gameCard";
|
||||||
|
import { MovieCreateType, MovieType } from "./movie/schemas/movie";
|
||||||
|
import { MovieCardType } from "./movie/schemas/movieCard";
|
||||||
|
import {
|
||||||
|
AudiobookCreateType,
|
||||||
|
AudiobookType,
|
||||||
|
} from "./audiobook/schemas/audiobook";
|
||||||
|
import { AudiobookCardType } from "./audiobook/schemas/audiobookCard";
|
||||||
|
|
||||||
|
export type ItemType = GameType | MovieType | AudiobookType;
|
||||||
|
export type ItemCardType = GameCardType | MovieCardType | AudiobookCardType;
|
||||||
|
export type ItemCreateType =
|
||||||
|
| GameCreateType
|
||||||
|
| MovieCreateType
|
||||||
|
| AudiobookCreateType;
|
||||||
|
|
||||||
|
export type UnionItemType = GameType & MovieType & AudiobookType;
|
||||||
|
export type UnionItemCardType = GameCardType &
|
||||||
|
MovieCardType &
|
||||||
|
AudiobookCardType;
|
||||||
|
export type UnionItemCreateType = GameCreateType &
|
||||||
|
MovieCreateType &
|
||||||
|
AudiobookCreateType;
|
||||||
|
|
||||||
|
export enum TypesOfItems {
|
||||||
|
game,
|
||||||
|
movie,
|
||||||
|
audiobook,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemSectionsType = "games" | "movies" | "audiobooks";
|
||||||
|
export const ItemSections = [
|
||||||
|
"games",
|
||||||
|
"movies",
|
||||||
|
"audiobooks",
|
||||||
|
] as ItemSectionsType[];
|
||||||
|
|
||||||
|
export const isSection = (a: string): a is ItemSectionsType => {
|
||||||
|
return (ItemSections as string[]).includes(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemPropertiesDescriptionType<T extends ItemType | ItemCreateType> =
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
key: keyof T;
|
||||||
|
value?: (item: T) => string;
|
||||||
|
editable?: boolean;
|
||||||
|
}[][];
|
||||||
|
|
||||||
|
export interface IItemService {
|
||||||
|
GetCards(): Promise<ItemCardType[] | null>;
|
||||||
|
Get(id: number): Promise<ItemType | null>;
|
||||||
|
Add(info: ItemCreateType): Promise<ItemType | null>;
|
||||||
|
Change(id: number, info: ItemCreateType): Promise<ItemType | null>;
|
||||||
|
GetEmpty(): ItemCreateType;
|
||||||
|
propertiesDescription: ItemPropertiesDescriptionType<UnionItemType>;
|
||||||
|
}
|
||||||
|
export const staticImplements =
|
||||||
|
<T>() =>
|
||||||
|
<U extends T>(constructor: U) =>
|
||||||
|
constructor;
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { GameCardType } from "@/entities/game";
|
|
||||||
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 && (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { GameCard } from "./gameCard";
|
|
||||||
|
|
||||||
export { GameCard };
|
|
||||||
3
src/features/itemCard/index.ts
Normal file
3
src/features/itemCard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { ItemCard } from "./itemCard";
|
||||||
|
|
||||||
|
export { ItemCard };
|
||||||
52
src/features/itemCard/itemCard.tsx
Normal file
52
src/features/itemCard/itemCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
isAudiobook,
|
||||||
|
isGame,
|
||||||
|
isMovie,
|
||||||
|
ItemCardType,
|
||||||
|
ItemService,
|
||||||
|
} from "@/entities/item";
|
||||||
|
import { Img } from "@/shared/ui";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const ItemCard = ({ card }: { card: ItemCardType }) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className="group/itemcard cursor-pointer"
|
||||||
|
href={"/" + ItemService.GetSectionUrlByItemType(card) + "/" + 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/itemcard:underline underline-offset-1">
|
||||||
|
{card.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isGame(card) && card.version && (
|
||||||
|
<span className="text-xs max-w-[30%] text-right line-clamp-2 text-fg4">
|
||||||
|
{card.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isMovie(card) && card.age && (
|
||||||
|
<span className="text-xs max-w-[30%] text-right line-clamp-2 text-fg4">
|
||||||
|
{card.age}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isAudiobook(card) && card.author && (
|
||||||
|
<span className="text-xs max-w-[40%] text-right line-clamp-2 text-fg4">
|
||||||
|
{card.author}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg tb:text-sm pr-2 text-justify line-clamp-5 text-fg4">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ export const Modal = ({ children }: { children: React.ReactNode }) => {
|
|||||||
!closing && "animate-fadeIn",
|
!closing && "animate-fadeIn",
|
||||||
closing && "animate-fadeOut opacity-0",
|
closing && "animate-fadeOut opacity-0",
|
||||||
"flex items-center justify-around",
|
"flex items-center justify-around",
|
||||||
"absolute z-20 left-0 w-full h-full bg-[#000000c5]"
|
"absolute z-30 left-0 w-full h-full bg-[#000000c5]"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setClosing(true);
|
setClosing(true);
|
||||||
|
|||||||
@@ -1,446 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
gameCreateSchema,
|
|
||||||
GameCreateType,
|
|
||||||
GameService,
|
|
||||||
GameType,
|
|
||||||
} from "@/entities/game";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { getYouTubeID } from "@/shared/utils";
|
|
||||||
import { UserService } from "@/entities/user";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { useCallback, useEffect, useRef, useState, useMemo } 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 isExistingGame = (game: GameCreateType | GameType): game is GameType => {
|
|
||||||
return (game as GameType).id !== undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GameInfo = ({
|
|
||||||
game: init_game,
|
|
||||||
}: {
|
|
||||||
game: GameCreateType | GameType;
|
|
||||||
}) => {
|
|
||||||
const [game, changeGame] = useState<GameCreateType | GameType>(init_game);
|
|
||||||
|
|
||||||
const { data: me } = useSWR("user", () => UserService.IdentifyYourself());
|
|
||||||
const [editable, setEditable] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (me) {
|
|
||||||
if (isExistingGame(game)) setEditable(me.id === game.owner_id);
|
|
||||||
else setEditable(true);
|
|
||||||
}
|
|
||||||
}, [me, game]);
|
|
||||||
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
setValue,
|
|
||||||
watch,
|
|
||||||
reset,
|
|
||||||
formState: { dirtyFields, errors },
|
|
||||||
} = useForm<GameCreateType>({
|
|
||||||
defaultValues: game,
|
|
||||||
resolver: zodResolver(gameCreateSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
register("torrent_file", { value: game.torrent_file });
|
|
||||||
register("cover", { value: game.cover });
|
|
||||||
}, [game.cover, game.torrent_file, register]);
|
|
||||||
|
|
||||||
const [savedTimeout, changeSavedTimeout] = useState<NodeJS.Timeout | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const watchedData = watch();
|
|
||||||
const [formData, changeFormData] = useState<GameCreateType | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(watchedData);
|
|
||||||
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(() => {
|
|
||||||
console.log("call", formRef.current);
|
|
||||||
if (formRef.current) formRef.current.requestSubmit();
|
|
||||||
}, 3000)
|
|
||||||
);
|
|
||||||
}, [watchedData]);
|
|
||||||
|
|
||||||
const onSubmit = async (formData: GameCreateType) => {
|
|
||||||
changeSavedTimeout(null);
|
|
||||||
if (isExistingGame(game)) {
|
|
||||||
const updatedGame = await GameService.ChangeGame(game.id, formData);
|
|
||||||
if (updatedGame) {
|
|
||||||
changeGame(updatedGame);
|
|
||||||
reset({}, { keepValues: true });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const addedGame = await GameService.AddGame(formData);
|
|
||||||
if (addedGame) {
|
|
||||||
changeGame(addedGame);
|
|
||||||
reset({}, { keepValues: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCoverDrop = useCallback(
|
|
||||||
(acceptedFiles: File[]) => {
|
|
||||||
const file = acceptedFiles[0];
|
|
||||||
const fileReader = new FileReader();
|
|
||||||
fileReader.onload = async () => {
|
|
||||||
const coverName = await FilesService.UploadCover(file);
|
|
||||||
if (coverName) {
|
|
||||||
setValue("cover", coverName, {
|
|
||||||
shouldValidate: true,
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fileReader.readAsDataURL(file);
|
|
||||||
},
|
|
||||||
[setValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
getRootProps: getCoverDropRootProps,
|
|
||||||
getInputProps: getCoverDropInputProps,
|
|
||||||
isDragActive: isCoverDragActive,
|
|
||||||
} = useDropzone({ onDrop: onCoverDrop });
|
|
||||||
|
|
||||||
const onTorrentDrop = useCallback(
|
|
||||||
(acceptedFiles: File[]) => {
|
|
||||||
const file = acceptedFiles[0];
|
|
||||||
const fileReader = new FileReader();
|
|
||||||
fileReader.onload = async () => {
|
|
||||||
const torrentName = await FilesService.UploadTorrent(file);
|
|
||||||
if (torrentName) {
|
|
||||||
setValue("torrent_file", torrentName, {
|
|
||||||
shouldValidate: true,
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fileReader.readAsDataURL(file);
|
|
||||||
},
|
|
||||||
[setValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => console.log(errors), [errors]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
getRootProps: getTorrentDropRootProps,
|
|
||||||
getInputProps: getTorrentDropInputProps,
|
|
||||||
isDragActive: isTorrentDragActive,
|
|
||||||
} = useDropzone({ onDrop: onTorrentDrop });
|
|
||||||
|
|
||||||
const [trailer, setTrailer] = useState<string | undefined>(game.trailer);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="m-4 flex flex-col lp:block"
|
|
||||||
ref={formRef}
|
|
||||||
>
|
|
||||||
{(watchedData.cover || editable) && (
|
|
||||||
<div className="lp:w-[60%] lp:px-4 lp:pl-0 pt-2 pb-4 float-left relative">
|
|
||||||
{watchedData.cover && (
|
|
||||||
<Img
|
|
||||||
src={watchedData.cover}
|
|
||||||
preview={false}
|
|
||||||
className="transition-all rounded-lg w-full object-contain"
|
|
||||||
width={1280}
|
|
||||||
height={720}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!watchedData.cover && editable && (
|
|
||||||
<div className="w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editable && (
|
|
||||||
<div
|
|
||||||
className="absolute h-full w-full mt-1 left-0 top-0 z-20 flex items-end"
|
|
||||||
{...(editable ? getCoverDropRootProps() : {})}
|
|
||||||
>
|
|
||||||
<input {...getCoverDropInputProps()} />
|
|
||||||
<span className="flex items-center ju w-full">
|
|
||||||
{isCoverDragActive ? (
|
|
||||||
<p className="w-full text-sm pl-2">Изменить обложку...</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="hidden lp:flex text-sm w-full justify-around text-fg4">
|
|
||||||
Для редактирования нажмите или перетащите новую обложку
|
|
||||||
поверх старой
|
|
||||||
</span>
|
|
||||||
<span className="text-xs lp:hidden w-full flex justify-around text-fg4">
|
|
||||||
Для редактирования нажмите на обложку и выберите фото
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
<span className="flex items-end justify-between relative pt-2">
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"text-fg4 text-2xl absolute -z-10 opacity-0",
|
|
||||||
watchedData.title === "" && "opacity-100",
|
|
||||||
"transition-opacity cursor-text"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Введите название
|
|
||||||
</span>
|
|
||||||
<h1
|
|
||||||
className={clsx(
|
|
||||||
"text-4xl outline-none max-w-[80%] cursor-text",
|
|
||||||
!editable && "cursor-default"
|
|
||||||
)}
|
|
||||||
suppressContentEditableWarning={true}
|
|
||||||
contentEditable={editable}
|
|
||||||
{...register("title", { value: game.title })}
|
|
||||||
onInput={(e) => {
|
|
||||||
setValue("title", e.currentTarget.innerText, {
|
|
||||||
shouldValidate: true,
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{game.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{editable && (
|
|
||||||
<span className="text-sm text-fg4 flex items-center cursor-default">
|
|
||||||
{savedTimeout && Object.keys(errors).length === 0 && (
|
|
||||||
<>
|
|
||||||
<SpinnerIcon className="mr-2" />
|
|
||||||
Редактируется
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{savedTimeout && Object.keys(errors).length > 0 && (
|
|
||||||
<span className="text-err text-right">Некорректные данные</span>
|
|
||||||
)}
|
|
||||||
{!savedTimeout && "Сохранено"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div className="text-err text-xs w-full h-2">
|
|
||||||
{errors.title?.message}
|
|
||||||
</div>
|
|
||||||
{(game.description || editable) && (
|
|
||||||
<span className="relative">
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"text-fg4 text-md absolute -z-10 opacity-0",
|
|
||||||
(watchedData.description === "" ||
|
|
||||||
watchedData.description === undefined) &&
|
|
||||||
"opacity-100",
|
|
||||||
"transition-opacity mt-2"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Введите описание
|
|
||||||
</span>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</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: isExistingGame(game)
|
|
||||||
? game.update_date.toLocaleDateString("ru-ru")
|
|
||||||
: new Date().toLocaleDateString("ru-ru"),
|
|
||||||
edit: false,
|
|
||||||
},
|
|
||||||
{ name: "Язык", key: "language" },
|
|
||||||
{ name: "Разработчик", key: "developer" },
|
|
||||||
{
|
|
||||||
name: "Год выхода",
|
|
||||||
key: "release_date",
|
|
||||||
},
|
|
||||||
{ name: "Объём загрузки", key: "download_size" },
|
|
||||||
],
|
|
||||||
] as {
|
|
||||||
name: string;
|
|
||||||
key: keyof GameCreateType;
|
|
||||||
value?: string;
|
|
||||||
edit?: boolean;
|
|
||||||
}[][]
|
|
||||||
).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="text-sm lp:text-md py-1">
|
|
||||||
<span className="font-bold">{req.name + ": "}</span>
|
|
||||||
<span className="relative">
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"text-fg4 opacity-0 absolute -z-10 text-xs",
|
|
||||||
(watchedData[req.key] === undefined ||
|
|
||||||
watchedData[req.key] === null ||
|
|
||||||
(watchedData[req.key] as string) === "") &&
|
|
||||||
"opacity-100 relative !z-10 text-base",
|
|
||||||
"transition-opacity "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Не известно
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"outline-none",
|
|
||||||
!editable && "cursor-default",
|
|
||||||
(watchedData[req.key] === undefined ||
|
|
||||||
watchedData[req.key] === null ||
|
|
||||||
(watchedData[req.key] as string) === "") &&
|
|
||||||
"opacity-100 absolute left-0 top-0 inline-block min-w-10 z-10"
|
|
||||||
)}
|
|
||||||
{...register(req.key, {
|
|
||||||
value: req.value ?? (game[req.key] as string),
|
|
||||||
})}
|
|
||||||
contentEditable={editable && (req.edit ?? true)}
|
|
||||||
suppressContentEditableWarning={true}
|
|
||||||
onInput={(e) => {
|
|
||||||
setValue(req.key, e.currentTarget.innerText, {
|
|
||||||
shouldValidate: true,
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{req.value ?? (game[req.key] as string)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{(trailer || editable) && (
|
|
||||||
<div className="w-ful aspect-video">
|
|
||||||
{trailer && getYouTubeID(trailer) && (
|
|
||||||
<iframe
|
|
||||||
src={"https://youtube.com/embed/" + getYouTubeID(trailer)}
|
|
||||||
className="w-full aspect-video rounded-lg mt-4"
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!trailer && editable && (
|
|
||||||
<div className="mt-4 w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editable && (
|
|
||||||
<div className="w-full flex justify-end pt-1">
|
|
||||||
<input
|
|
||||||
className="outline-none w-full lp:w-2/3 text-xs lp:text-base bg-bg1"
|
|
||||||
{...register("trailer", {
|
|
||||||
value: game.trailer,
|
|
||||||
onChange: (e) => {
|
|
||||||
setTrailer(e.target.value);
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
defaultValue={game.trailer}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="relative w-full flex items-center justify-around pt-4"
|
|
||||||
{...(editable ? getTorrentDropRootProps() : {})}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
process.env.NEXT_PUBLIC_CONTENT_URL +
|
|
||||||
"/" +
|
|
||||||
watchedData.torrent_file
|
|
||||||
}
|
|
||||||
className={clsx(
|
|
||||||
"p-4 bg-ac0 text-fg1 text-2xl rounded-lg",
|
|
||||||
!watchedData.torrent_file && "bg-bg1 text-fg4"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Скачать {watchedData.title}
|
|
||||||
</Link>
|
|
||||||
{editable && (
|
|
||||||
<>
|
|
||||||
<input {...getTorrentDropInputProps()} />
|
|
||||||
<span className="flex flex-col items-center w-full p-1 text-fg4 text-xs lp:text-sm cursor-pointer">
|
|
||||||
{errors.torrent_file && (
|
|
||||||
<span className="w-full text-center text-err">
|
|
||||||
{errors.torrent_file.message}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isTorrentDragActive ? (
|
|
||||||
<span className="w-full text-center">
|
|
||||||
Изменить .torrent файл...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="w-full text-center">
|
|
||||||
Нажмите, чтобы изменить <br />
|
|
||||||
.torrent файл
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { GameInfo } from "./gameInfo";
|
|
||||||
|
|
||||||
export { GameInfo };
|
|
||||||
@@ -9,9 +9,9 @@ import clsx from "clsx";
|
|||||||
import { UserActivities } from "@/features/userActivities";
|
import { UserActivities } from "@/features/userActivities";
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{ title: "Игры", href: "games" },
|
{ title: "Игры", href: "/games" },
|
||||||
{ title: "Фильмы", href: "films" },
|
{ title: "Фильмы", href: "/movies" },
|
||||||
{ title: "Аудиокниги", href: "audiobooks" },
|
{ title: "Аудиокниги", href: "/audiobooks" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = () => {
|
||||||
|
|||||||
3
src/widgets/itemInfo/index.ts
Normal file
3
src/widgets/itemInfo/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { ItemInfo } from "./itemInfo";
|
||||||
|
|
||||||
|
export { ItemInfo };
|
||||||
93
src/widgets/itemInfo/itemCover.tsx
Normal file
93
src/widgets/itemInfo/itemCover.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { FilesService } from "@/entities/files";
|
||||||
|
import { ItemCreateType, ItemType } from "@/entities/item";
|
||||||
|
import { Img } from "@/shared/ui";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { UseFormSetValue } from "react-hook-form";
|
||||||
|
|
||||||
|
export const ItemCover = ({
|
||||||
|
cover,
|
||||||
|
editable,
|
||||||
|
setFormValue: setValue,
|
||||||
|
}: {
|
||||||
|
cover: string | null | undefined;
|
||||||
|
editable: boolean;
|
||||||
|
setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
|
||||||
|
}) => {
|
||||||
|
const onCoverDrop = useCallback(
|
||||||
|
(acceptedFiles: File[]) => {
|
||||||
|
const file = acceptedFiles[0];
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.onload = async () => {
|
||||||
|
const coverName = await FilesService.UploadCover(file);
|
||||||
|
if (coverName) {
|
||||||
|
setValue("cover", coverName, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fileReader.readAsDataURL(file);
|
||||||
|
},
|
||||||
|
[setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
getRootProps: getCoverDropRootProps,
|
||||||
|
getInputProps: getCoverDropInputProps,
|
||||||
|
isDragActive: isCoverDragActive,
|
||||||
|
} = useDropzone({
|
||||||
|
onDrop: onCoverDrop,
|
||||||
|
accept: {
|
||||||
|
"image/jpeg": [],
|
||||||
|
"image/png": [],
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(cover || editable) && (
|
||||||
|
<div className="lp:w-[60%] lp:px-4 lp:pl-0 pt-2 pb-4 float-left relative">
|
||||||
|
{cover && (
|
||||||
|
<Img
|
||||||
|
src={cover}
|
||||||
|
preview={false}
|
||||||
|
className="transition-all rounded-lg w-full object-contain"
|
||||||
|
width={1280}
|
||||||
|
height={720}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!cover && editable && (
|
||||||
|
<div className="w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<div
|
||||||
|
className="absolute h-full w-full mt-1 left-0 top-0 z-20 flex items-end"
|
||||||
|
{...(editable ? getCoverDropRootProps() : {})}
|
||||||
|
>
|
||||||
|
<input {...getCoverDropInputProps()} />
|
||||||
|
<span className="flex items-center ju w-full">
|
||||||
|
{isCoverDragActive ? (
|
||||||
|
<p className="w-full text-sm pl-2">Изменить обложку...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="hidden lp:flex text-sm w-full justify-around text-fg4">
|
||||||
|
Для редактирования нажмите или перетащите новую обложку
|
||||||
|
поверх старой
|
||||||
|
</span>
|
||||||
|
<span className="text-xs lp:hidden w-full flex justify-around text-fg4">
|
||||||
|
Для редактирования нажмите на обложку и выберите фото
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
115
src/widgets/itemInfo/itemDetails.tsx
Normal file
115
src/widgets/itemInfo/itemDetails.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { ItemCreateType, ItemType } from "@/entities/item";
|
||||||
|
import { UseFormRegister, UseFormSetValue } from "react-hook-form";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { SpinnerIcon } from "@/shared/assets/icons";
|
||||||
|
|
||||||
|
export const ItemDetails = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
editable,
|
||||||
|
state,
|
||||||
|
registerFormField: register,
|
||||||
|
setFormValue: setValue,
|
||||||
|
}: {
|
||||||
|
title: {
|
||||||
|
title: string;
|
||||||
|
default_title: string;
|
||||||
|
error: string | undefined;
|
||||||
|
};
|
||||||
|
description: {
|
||||||
|
description: string | null | undefined;
|
||||||
|
default_description: string | null | undefined;
|
||||||
|
};
|
||||||
|
editable: boolean;
|
||||||
|
state: "saved" | "editing" | "error";
|
||||||
|
registerFormField: UseFormRegister<ItemType | ItemCreateType>;
|
||||||
|
setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span className="flex items-end justify-between relative pt-2">
|
||||||
|
{editable && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"text-fg4 text-2xl absolute -z-10 opacity-0",
|
||||||
|
title.title === "" && "opacity-100",
|
||||||
|
"transition-opacity cursor-text"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Введите название
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h1
|
||||||
|
className={clsx(
|
||||||
|
"text-4xl outline-none max-w-[80%] cursor-text",
|
||||||
|
!editable && "cursor-default"
|
||||||
|
)}
|
||||||
|
suppressContentEditableWarning={true}
|
||||||
|
contentEditable={editable}
|
||||||
|
{...register("title", { value: title.default_title })}
|
||||||
|
onInput={(e) => {
|
||||||
|
setValue("title", e.currentTarget.innerText, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title.default_title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<span className="text-sm text-fg4 flex items-center cursor-default">
|
||||||
|
{}
|
||||||
|
{state === "editing" && (
|
||||||
|
<>
|
||||||
|
<SpinnerIcon className="mr-2" />
|
||||||
|
Редактируется
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "error" && (
|
||||||
|
<span className="text-err text-right">Некорректные данные</span>
|
||||||
|
)}
|
||||||
|
{state === "saved" && "Сохранено"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="text-err text-xs w-full h-2">{title.error}</div>
|
||||||
|
{(description.default_description || editable) && (
|
||||||
|
<span className="relative">
|
||||||
|
{editable && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"text-fg4 text-md absolute -z-10 opacity-0",
|
||||||
|
(description.description === "" || description === undefined) &&
|
||||||
|
"opacity-100",
|
||||||
|
"transition-opacity mt-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Введите описание
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
contentEditable={editable}
|
||||||
|
suppressContentEditableWarning={true}
|
||||||
|
className={clsx(
|
||||||
|
"text-md text-justify",
|
||||||
|
"text-fg4 pt-2 outline-none",
|
||||||
|
!editable && "cursor-default"
|
||||||
|
)}
|
||||||
|
{...register("description", {
|
||||||
|
value: description.default_description,
|
||||||
|
})}
|
||||||
|
onInput={(e) => {
|
||||||
|
setValue("description", e.currentTarget.innerText, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description.default_description}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
81
src/widgets/itemInfo/itemFragment.tsx
Normal file
81
src/widgets/itemInfo/itemFragment.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { FilesService } from "@/entities/files";
|
||||||
|
import { ItemCreateType, ItemType } from "@/entities/item";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { UseFormRegister, UseFormSetValue } from "react-hook-form";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export const ItemFragment = ({
|
||||||
|
fragment,
|
||||||
|
editable,
|
||||||
|
setFormValue: setValue,
|
||||||
|
registerFormField: register,
|
||||||
|
}: {
|
||||||
|
fragment: string | undefined | null;
|
||||||
|
editable: boolean;
|
||||||
|
setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
|
||||||
|
registerFormField: UseFormRegister<ItemType | ItemCreateType>;
|
||||||
|
}) => {
|
||||||
|
const onFragmentDrop = useCallback(
|
||||||
|
(acceptedFiles: File[]) => {
|
||||||
|
const file = acceptedFiles[0];
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.onload = async () => {
|
||||||
|
const fragmentName = await FilesService.UploadFragment(file);
|
||||||
|
if (fragmentName) {
|
||||||
|
setValue("fragment", fragmentName, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fileReader.readAsDataURL(file);
|
||||||
|
},
|
||||||
|
[setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
getRootProps: getFragmentDropRootProps,
|
||||||
|
getInputProps: getFragmentDropInputProps,
|
||||||
|
isDragActive: isFragmentDragActive,
|
||||||
|
} = useDropzone({
|
||||||
|
onDrop: onFragmentDrop,
|
||||||
|
accept: {
|
||||||
|
"audio/mpeg": [],
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative w-full flex items-center justify-around pt-4"
|
||||||
|
{...(editable ? getFragmentDropRootProps() : {})}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center w-[80%] h-20">
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
controlsList="nodownload"
|
||||||
|
typeof="audio/mpeg"
|
||||||
|
src={process.env.NEXT_PUBLIC_FRAGMENT_URL + "/" + (fragment ?? "")}
|
||||||
|
className={clsx(!fragment && "pointer-events-none", "w-full h-full")}
|
||||||
|
/>
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<input {...getFragmentDropInputProps()} />
|
||||||
|
<span className="flex flex-col items-center w-full p-1 text-fg4 text-xs lp:text-sm cursor-pointer">
|
||||||
|
{isFragmentDragActive ? (
|
||||||
|
<span className="w-full text-center">Изменить фрагмент...</span>
|
||||||
|
) : (
|
||||||
|
<span className="w-full text-center">
|
||||||
|
Для редактирования нажмите или перетащите новый фрагмент
|
||||||
|
поверх старого
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
165
src/widgets/itemInfo/itemInfo.tsx
Normal file
165
src/widgets/itemInfo/itemInfo.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { isAudiobook, isGame, ItemCreateType, ItemType } from "@/entities/item";
|
||||||
|
import { UserService } from "@/entities/user";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ItemCover } from "./itemCover";
|
||||||
|
import { ItemService } from "@/entities/item/item";
|
||||||
|
import { ItemProperties } from "./itemProperties";
|
||||||
|
import { ItemTrailer } from "./itemTrailer";
|
||||||
|
import { ItemTorrent } from "./itemTorrent";
|
||||||
|
import { ItemDetails } from "./itemDetails";
|
||||||
|
import { isMovie } from "@/entities/item/movie/schemas/movie";
|
||||||
|
import { ItemFragment } from "./itemFragment";
|
||||||
|
|
||||||
|
export const ItemInfo = <T extends ItemType | ItemCreateType>({
|
||||||
|
item: init_item,
|
||||||
|
}: {
|
||||||
|
item: T;
|
||||||
|
}) => {
|
||||||
|
const [item, changeItem] = useState<T>(init_item);
|
||||||
|
|
||||||
|
const { data: me } = useSWR("user", () => UserService.IdentifyYourself());
|
||||||
|
const [editable, setEditable] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (me) {
|
||||||
|
if (ItemService.isExistingItem(item))
|
||||||
|
setEditable(me.id === item.owner_id);
|
||||||
|
else setEditable(true);
|
||||||
|
}
|
||||||
|
}, [me, item]);
|
||||||
|
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
formState: { dirtyFields, errors },
|
||||||
|
} = useForm<ItemType | ItemCreateType>({
|
||||||
|
// Unfortunately, react hook form does not accept generic type correctly
|
||||||
|
// useForm<T> causes an error when calling register(key) ->
|
||||||
|
// key is not assignable to parameter of type 'Path<T>'
|
||||||
|
defaultValues: init_item,
|
||||||
|
resolver: zodResolver(ItemService.GetFormResolver(item)),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
register("torrent_file", { value: item.torrent_file });
|
||||||
|
register("cover", { value: item.cover });
|
||||||
|
if (isAudiobook(item)) register("fragment", { value: item.fragment });
|
||||||
|
}, [item.cover, item.torrent_file, register]);
|
||||||
|
|
||||||
|
const [savedTimeout, changeSavedTimeout] = useState<NodeJS.Timeout | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const watchedData = watch();
|
||||||
|
|
||||||
|
const [formData, changeFormData] = useState<T | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Object.keys(dirtyFields).length) return;
|
||||||
|
if (JSON.stringify(watchedData) === JSON.stringify(formData)) return;
|
||||||
|
console.log(dirtyFields);
|
||||||
|
changeFormData(watchedData as T);
|
||||||
|
if (savedTimeout) clearTimeout(savedTimeout);
|
||||||
|
changeSavedTimeout(
|
||||||
|
setTimeout(() => {
|
||||||
|
if (formRef.current) formRef.current.requestSubmit();
|
||||||
|
}, 3000)
|
||||||
|
);
|
||||||
|
}, [watchedData]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: ItemCreateType) => {
|
||||||
|
changeSavedTimeout(null);
|
||||||
|
const updatedItem = ItemService.isExistingItem(item)
|
||||||
|
? await ItemService.ChangeItem(item.id, formData)
|
||||||
|
: await ItemService.AddItem(formData);
|
||||||
|
if (updatedItem) {
|
||||||
|
changeItem(updatedItem as T);
|
||||||
|
reset({}, { keepValues: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => console.log(errors), [errors]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="m-4 flex flex-col lp:block"
|
||||||
|
ref={formRef}
|
||||||
|
>
|
||||||
|
<ItemCover
|
||||||
|
cover={watchedData.cover}
|
||||||
|
editable={editable}
|
||||||
|
setFormValue={setValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ItemDetails
|
||||||
|
title={{
|
||||||
|
title: watchedData.title,
|
||||||
|
default_title: item.title,
|
||||||
|
error: errors.title?.message,
|
||||||
|
}}
|
||||||
|
description={{
|
||||||
|
description: watchedData.description,
|
||||||
|
default_description: item.description,
|
||||||
|
}}
|
||||||
|
editable={editable}
|
||||||
|
state={
|
||||||
|
savedTimeout
|
||||||
|
? Object.keys(errors).length > 0
|
||||||
|
? "error"
|
||||||
|
: "editing"
|
||||||
|
: "saved"
|
||||||
|
}
|
||||||
|
registerFormField={register}
|
||||||
|
setFormValue={setValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ItemProperties
|
||||||
|
item={item}
|
||||||
|
watchedFormData={watchedData}
|
||||||
|
editable={editable}
|
||||||
|
setFormValue={setValue}
|
||||||
|
registerFormField={register}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(isGame(item) || isMovie(item)) &&
|
||||||
|
(isGame(watchedData) || isMovie(watchedData)) && (
|
||||||
|
<ItemTrailer
|
||||||
|
default_trailer={item.trailer}
|
||||||
|
trailer={watchedData.trailer}
|
||||||
|
editable={editable}
|
||||||
|
registerFormField={register}
|
||||||
|
setFormValue={setValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAudiobook(watchedData) && (
|
||||||
|
<ItemFragment
|
||||||
|
fragment={watchedData.fragment}
|
||||||
|
editable={editable}
|
||||||
|
registerFormField={register}
|
||||||
|
setFormValue={setValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ItemTorrent
|
||||||
|
title={watchedData.title}
|
||||||
|
torrent_file={watchedData.torrent_file}
|
||||||
|
editable={editable}
|
||||||
|
error={errors.torrent_file?.message}
|
||||||
|
setFormValue={setValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input type="submit" className="hidden" />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
84
src/widgets/itemInfo/itemProperties.tsx
Normal file
84
src/widgets/itemInfo/itemProperties.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ItemCreateType, ItemType } from "@/entities/item";
|
||||||
|
import { ItemService } from "@/entities/item/item";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { UseFormRegister, UseFormSetValue } from "react-hook-form";
|
||||||
|
|
||||||
|
export const ItemProperties = <T extends ItemType | ItemCreateType>({
|
||||||
|
item,
|
||||||
|
watchedFormData: watchedData,
|
||||||
|
editable,
|
||||||
|
setFormValue: setValue,
|
||||||
|
registerFormField: register,
|
||||||
|
}: {
|
||||||
|
item: T; // Init values
|
||||||
|
watchedFormData: T; // Updated values
|
||||||
|
editable: boolean;
|
||||||
|
setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
|
||||||
|
registerFormField: UseFormRegister<ItemType | ItemCreateType>;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-full flex justify-between pt-4",
|
||||||
|
!editable && "cursor-default"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(ItemService.GetPropertiesDescriptionForItem(item) ?? []).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="text-sm lp:text-md py-1">
|
||||||
|
<span className="font-bold">{req.name + ": "}</span>
|
||||||
|
<span className="relative">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"text-fg4 opacity-0 absolute -z-10 text-xs",
|
||||||
|
(watchedData[req.key] === undefined ||
|
||||||
|
watchedData[req.key] === null ||
|
||||||
|
(watchedData[req.key] as string) === "") &&
|
||||||
|
"opacity-100 relative !z-10 text-base",
|
||||||
|
"transition-opacity "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Не известно
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"outline-none",
|
||||||
|
!editable && "cursor-default",
|
||||||
|
(watchedData[req.key] === undefined ||
|
||||||
|
watchedData[req.key] === null ||
|
||||||
|
(watchedData[req.key] as string) === "") &&
|
||||||
|
"opacity-100 absolute left-0 top-0 inline-block min-w-10 z-10"
|
||||||
|
)}
|
||||||
|
{...register(req.key as keyof ItemType, {
|
||||||
|
value: req.value
|
||||||
|
? req.value(item)
|
||||||
|
: undefined ?? (item[req.key] as string),
|
||||||
|
})}
|
||||||
|
contentEditable={editable && (req.editable ?? true)}
|
||||||
|
suppressContentEditableWarning={true}
|
||||||
|
onInput={(e) => {
|
||||||
|
setValue(
|
||||||
|
req.key as keyof ItemType,
|
||||||
|
e.currentTarget.innerText,
|
||||||
|
{
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{req.value
|
||||||
|
? req.value(item)
|
||||||
|
: undefined ?? (item[req.key] as string)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
src/widgets/itemInfo/itemTorrent.tsx
Normal file
102
src/widgets/itemInfo/itemTorrent.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { FilesService } from "@/entities/files";
|
||||||
|
import { ItemCreateType, ItemType } from "@/entities/item";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { UseFormSetValue } from "react-hook-form";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export const ItemTorrent = ({
|
||||||
|
title,
|
||||||
|
torrent_file,
|
||||||
|
editable,
|
||||||
|
error,
|
||||||
|
setFormValue: setValue,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
torrent_file: string;
|
||||||
|
editable: boolean;
|
||||||
|
error: string | undefined;
|
||||||
|
setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
|
||||||
|
}) => {
|
||||||
|
const onTorrentDrop = useCallback(
|
||||||
|
(acceptedFiles: File[]) => {
|
||||||
|
const file = acceptedFiles[0];
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.onload = async () => {
|
||||||
|
const torrentName = await FilesService.UploadTorrent(file);
|
||||||
|
if (torrentName) {
|
||||||
|
setValue("torrent_file", torrentName, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fileReader.readAsDataURL(file);
|
||||||
|
},
|
||||||
|
[setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
getRootProps: getTorrentDropRootProps,
|
||||||
|
getInputProps: getTorrentDropInputProps,
|
||||||
|
isDragActive: isTorrentDragActive,
|
||||||
|
} = useDropzone({
|
||||||
|
onDrop: onTorrentDrop,
|
||||||
|
accept: {
|
||||||
|
"application/octet-stream": [".torrent"],
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="relative w-full flex items-center justify-around pt-4"
|
||||||
|
{...(editable ? getTorrentDropRootProps() : {})}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<Link
|
||||||
|
href={process.env.NEXT_PUBLIC_CONTENT_URL + "/" + torrent_file}
|
||||||
|
className={clsx(
|
||||||
|
"p-4 bg-ac0 text-fg1 text-2xl rounded-lg",
|
||||||
|
!torrent_file && "bg-bg1 text-fg4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Скачать {title}
|
||||||
|
</Link>
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<input {...getTorrentDropInputProps()} />
|
||||||
|
<span className="flex flex-col items-center w-full p-1 text-fg4 text-xs lp:text-sm cursor-pointer">
|
||||||
|
{error && (
|
||||||
|
<span className="w-full text-center text-err">{error}</span>
|
||||||
|
)}
|
||||||
|
{isTorrentDragActive ? (
|
||||||
|
<span className="w-full text-center">
|
||||||
|
Изменить .torrent файл...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="w-full text-center">
|
||||||
|
Нажмите, чтобы изменить <br />
|
||||||
|
.torrent файл
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
src/widgets/itemInfo/itemTrailer.tsx
Normal file
51
src/widgets/itemInfo/itemTrailer.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { ItemCreateType, ItemType } from "@/entities/item";
|
||||||
|
import { getYouTubeID } from "@/shared/utils";
|
||||||
|
import { UseFormRegister, UseFormSetValue } from "react-hook-form";
|
||||||
|
|
||||||
|
export const ItemTrailer = ({
|
||||||
|
trailer,
|
||||||
|
default_trailer,
|
||||||
|
editable,
|
||||||
|
setFormValue: setValue,
|
||||||
|
registerFormField: register,
|
||||||
|
}: {
|
||||||
|
trailer: string | undefined;
|
||||||
|
default_trailer: string | undefined;
|
||||||
|
editable: boolean;
|
||||||
|
setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
|
||||||
|
registerFormField: UseFormRegister<ItemType | ItemCreateType>;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(trailer || editable) && (
|
||||||
|
<div className="w-ful aspect-video">
|
||||||
|
{trailer && getYouTubeID(trailer) && (
|
||||||
|
<iframe
|
||||||
|
src={"https://youtube.com/embed/" + getYouTubeID(trailer)}
|
||||||
|
className="w-full aspect-video rounded-lg mt-4"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!trailer && editable && (
|
||||||
|
<div className="mt-4 w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<div className="w-full flex justify-end pt-1">
|
||||||
|
<input
|
||||||
|
className="outline-none w-full lp:w-2/3 text-xs lp:text-base bg-bg1"
|
||||||
|
{...register("trailer", {
|
||||||
|
value: default_trailer,
|
||||||
|
onChange: (e) => {
|
||||||
|
setValue("trailer", e.target.value);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
defaultValue={default_trailer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
},
|
||||||
|
"experimentalDecorators": true
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
Reference in New Issue
Block a user