diff --git a/.env.development b/.env.development index 5b3b7ea..5dd0555 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,5 @@ 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_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_PREVIEW_URL=http://127.0.0.1:8000/content/images/cover/preview \ No newline at end of file diff --git a/src/app/[section]/[item_id]/page.tsx b/src/app/[section]/[item_id]/page.tsx new file mode 100644 index 0000000..5b0240c --- /dev/null +++ b/src/app/[section]/[item_id]/page.tsx @@ -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 && } + + {cards && ( +
+ {cards.map((card) => ( + + ))} +
+ )} + + ); +} diff --git a/src/app/[section]/add/page.tsx b/src/app/[section]/add/page.tsx new file mode 100644 index 0000000..488792e --- /dev/null +++ b/src/app/[section]/add/page.tsx @@ -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 ( + <> + + + {cards && ( +
+ {cards.map((card) => ( + + ))} +
+ )} + + ); +} diff --git a/src/app/[section]/page.tsx b/src/app/[section]/page.tsx new file mode 100644 index 0000000..70a9758 --- /dev/null +++ b/src/app/[section]/page.tsx @@ -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 && ( +
+ {cards.map((card) => ( + + ))} +
+ )} + + ); +} diff --git a/src/app/games/[game_id]/page.tsx b/src/app/games/[game_id]/page.tsx deleted file mode 100644 index 891cb76..0000000 --- a/src/app/games/[game_id]/page.tsx +++ /dev/null @@ -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 && } - - {gameCards && ( -
- {gameCards.map((card) => ( - - ))} -
- )} - - ); -} diff --git a/src/app/games/add/page.tsx b/src/app/games/add/page.tsx deleted file mode 100644 index 658e902..0000000 --- a/src/app/games/add/page.tsx +++ /dev/null @@ -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 ( - <> - - - {gameCards && ( -
- {gameCards.map((card) => ( - - ))} -
- )} - - ); -} diff --git a/src/app/games/page.tsx b/src/app/games/page.tsx deleted file mode 100644 index fd11cb8..0000000 --- a/src/app/games/page.tsx +++ /dev/null @@ -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 && ( -
- {gameCards.map((card) => ( - - ))} -
- )} - - ); -} diff --git a/src/app/globals.css b/src/app/globals.css index c65d8a0..322e869 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -52,8 +52,6 @@ body { overflow: hidden; color: var(--color-fg1); background-color: var(--color-bg0); - - } body * { @@ -61,6 +59,10 @@ body * { scrollbar-width: thin; } +audio::-webkit-media-controls-panel { + background-color: var(--color-bg1); +} + @media (max-width: 1024px) { :root { font-size: calc((100vw / 1920) * 56); diff --git a/src/app/page.tsx b/src/app/page.tsx index 88ad412..d72a017 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,11 @@ -import { GameService } from "@/entities/game"; -import { GameCard } from "@/features/gameCard"; +import { + isSection, + ItemCardType, + ItemSections, + ItemSectionsType, + ItemService, +} from "@/entities/item"; +import { ItemCard } from "@/features/itemCard"; import { Section } from "@/widgets/section"; import { Metadata } from "next"; @@ -10,20 +16,32 @@ export const metadata: Metadata = { }; 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 ( <> - {gameCards && gameCards.length > 0 && ( -
- {gameCards.map((card) => ( - - ))} -
- )} + {ItemSections.map((section) => ( +
+ {cards[section] && cards[section].length > 0 && ( +
+ {cards[section].map((card) => ( + + ))} +
+ )} +
+ ))} ); } diff --git a/src/entities/files/files.ts b/src/entities/files/files.ts index 51583a2..03e0df0 100644 --- a/src/entities/files/files.ts +++ b/src/entities/files/files.ts @@ -1,5 +1,7 @@ import { HTTPService } from "@/shared/utils/http"; import { coverNameSchema } from "./schemas/cover"; +import { torrentNameSchema } from "./schemas/torrent"; +import { fragmentNameSchema } from "./schemas/fragment"; export abstract class FilesService { public static async UploadCover(cover: File) { @@ -19,7 +21,19 @@ export abstract class FilesService { formData.append("torrent", torrent); return await HTTPService.post( `/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, {}, false diff --git a/src/entities/files/schemas/fragment.ts b/src/entities/files/schemas/fragment.ts new file mode 100644 index 0000000..f8e362e --- /dev/null +++ b/src/entities/files/schemas/fragment.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; + +export const fragmentNameSchema = z.string().min(5); +export type FragmentNameType = z.infer; diff --git a/src/entities/files/schemas/torrent.ts b/src/entities/files/schemas/torrent.ts new file mode 100644 index 0000000..3663d97 --- /dev/null +++ b/src/entities/files/schemas/torrent.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; + +export const torrentNameSchema = z.string().min(5); +export type TorrentNameType = z.infer; diff --git a/src/entities/game/game.ts b/src/entities/game/game.ts deleted file mode 100644 index 4a17508..0000000 --- a/src/entities/game/game.ts +++ /dev/null @@ -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: "", - }; - } -} diff --git a/src/entities/game/index.ts b/src/entities/game/index.ts deleted file mode 100644 index db43b3b..0000000 --- a/src/entities/game/index.ts +++ /dev/null @@ -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, -}; diff --git a/src/entities/item/audiobook/audiobook.ts b/src/entities/item/audiobook/audiobook.ts new file mode 100644 index 0000000..1698c2d --- /dev/null +++ b/src/entities/item/audiobook/audiobook.ts @@ -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() +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 = [ + [ + { 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" }, + ], + ]; +} diff --git a/src/entities/item/audiobook/schemas/audiobook.ts b/src/entities/item/audiobook/schemas/audiobook.ts new file mode 100644 index 0000000..b41bb60 --- /dev/null +++ b/src/entities/item/audiobook/schemas/audiobook.ts @@ -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; + +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; + +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; +}); diff --git a/src/entities/item/audiobook/schemas/audiobookCard.ts b/src/entities/item/audiobook/schemas/audiobookCard.ts new file mode 100644 index 0000000..e4512f2 --- /dev/null +++ b/src/entities/item/audiobook/schemas/audiobookCard.ts @@ -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; + +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 + ); +}; diff --git a/src/entities/item/game/game.ts b/src/entities/item/game/game.ts new file mode 100644 index 0000000..24e38d9 --- /dev/null +++ b/src/entities/item/game/game.ts @@ -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() +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 = [ + [ + { 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" }, + ], + ]; +} diff --git a/src/entities/game/schemas/game.ts b/src/entities/item/game/schemas/game.ts similarity index 100% rename from src/entities/game/schemas/game.ts rename to src/entities/item/game/schemas/game.ts diff --git a/src/entities/game/schemas/gameCard.ts b/src/entities/item/game/schemas/gameCard.ts similarity index 55% rename from src/entities/game/schemas/gameCard.ts rename to src/entities/item/game/schemas/gameCard.ts index 4bf3b8b..caa1765 100644 --- a/src/entities/game/schemas/gameCard.ts +++ b/src/entities/item/game/schemas/gameCard.ts @@ -1,10 +1,18 @@ import { z } from "zod"; +import { TypesOfItems } from "../../types"; export const gameCardBaseSchema = z.object({ title: z.string().min(3, "Слишком короткое название"), cover: z.string().optional().nullable(), description: z.string().optional().nullable(), version: z.string().optional().nullable(), + + // Добавляем к каждой игре поле, которое + // показывает, что item является игрой + type: z + .any() + .optional() + .transform(() => TypesOfItems.game), }); export const gameCardSchema = gameCardBaseSchema.merge( @@ -14,15 +22,22 @@ export const gameCardSchema = gameCardBaseSchema.merge( ); export type GameCardType = z.infer; -export const isGameCard = (a: any): a is GameCardType => { +export const isGameCardStrict = (a: any): a is GameCardType => { return gameCardSchema.safeParse(a).success; }; export const gameCardsSchema = z.array(z.any()).transform((a) => { const cards: GameCardType[] = []; a.forEach((e) => { - if (isGameCard(e)) cards.push(gameCardSchema.parse(e)); + if (isGameCardStrict(e)) cards.push(gameCardSchema.parse(e)); else console.error("GameCard parse error - ", e); }); return cards; }); + +export const isGame = (a: any): a is GameCardType => { + return ( + gameCardBaseSchema.safeParse(a).success && + (a as GameCardType).type === TypesOfItems.game + ); +}; diff --git a/src/entities/item/index.ts b/src/entities/item/index.ts new file mode 100644 index 0000000..603c1f3 --- /dev/null +++ b/src/entities/item/index.ts @@ -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, +}; diff --git a/src/entities/item/item.ts b/src/entities/item/item.ts new file mode 100644 index 0000000..89349f4 --- /dev/null +++ b/src/entities/item/item.ts @@ -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; + AddItem: (itemInfo: ItemCreateType) => Promise; + ChangeItem: ( + id: number, + itemInfo: ItemCreateType + ) => Promise; + }; + } { + 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; + } + + 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 + ); + } +} diff --git a/src/entities/item/movie/movie.ts b/src/entities/item/movie/movie.ts new file mode 100644 index 0000000..9ef724b --- /dev/null +++ b/src/entities/item/movie/movie.ts @@ -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() +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 = + [ + [ + { 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" }, + ], + ]; +} diff --git a/src/entities/item/movie/schemas/movie.ts b/src/entities/item/movie/schemas/movie.ts new file mode 100644 index 0000000..04fd74e --- /dev/null +++ b/src/entities/item/movie/schemas/movie.ts @@ -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; + +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; + +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; +}); diff --git a/src/entities/item/movie/schemas/movieCard.ts b/src/entities/item/movie/schemas/movieCard.ts new file mode 100644 index 0000000..042b2fd --- /dev/null +++ b/src/entities/item/movie/schemas/movieCard.ts @@ -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; + +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 + ); +}; diff --git a/src/entities/item/types.ts b/src/entities/item/types.ts new file mode 100644 index 0000000..d19e88a --- /dev/null +++ b/src/entities/item/types.ts @@ -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 = + { + name: string; + key: keyof T; + value?: (item: T) => string; + editable?: boolean; + }[][]; + +export interface IItemService { + GetCards(): Promise; + Get(id: number): Promise; + Add(info: ItemCreateType): Promise; + Change(id: number, info: ItemCreateType): Promise; + GetEmpty(): ItemCreateType; + propertiesDescription: ItemPropertiesDescriptionType; +} +export const staticImplements = + () => + (constructor: U) => + constructor; diff --git a/src/features/gameCard/gameCard.tsx b/src/features/gameCard/gameCard.tsx deleted file mode 100644 index e7cfe3a..0000000 --- a/src/features/gameCard/gameCard.tsx +++ /dev/null @@ -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 ( - - {!!card.cover && ( - - )} -
-

- {card.title} -

- {card.version && ( - - {card.version} - - )} -
-

- {card.description} -

- - ); -}; diff --git a/src/features/gameCard/index.ts b/src/features/gameCard/index.ts deleted file mode 100644 index fcda3e6..0000000 --- a/src/features/gameCard/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { GameCard } from "./gameCard"; - -export { GameCard }; diff --git a/src/features/itemCard/index.ts b/src/features/itemCard/index.ts new file mode 100644 index 0000000..de3f029 --- /dev/null +++ b/src/features/itemCard/index.ts @@ -0,0 +1,3 @@ +import { ItemCard } from "./itemCard"; + +export { ItemCard }; diff --git a/src/features/itemCard/itemCard.tsx b/src/features/itemCard/itemCard.tsx new file mode 100644 index 0000000..d5ceec5 --- /dev/null +++ b/src/features/itemCard/itemCard.tsx @@ -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 ( + + {!!card.cover && ( + + )} +
+

+ {card.title} +

+ + {isGame(card) && card.version && ( + + {card.version} + + )} + {isMovie(card) && card.age && ( + + {card.age} + + )} + {isAudiobook(card) && card.author && ( + + {card.author} + + )} +
+

+ {card.description} +

+ + ); +}; diff --git a/src/shared/ui/modal.tsx b/src/shared/ui/modal.tsx index b8871a6..a2ab468 100644 --- a/src/shared/ui/modal.tsx +++ b/src/shared/ui/modal.tsx @@ -13,7 +13,7 @@ export const Modal = ({ children }: { children: React.ReactNode }) => { !closing && "animate-fadeIn", closing && "animate-fadeOut opacity-0", "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={() => { setClosing(true); diff --git a/src/widgets/gameInfo/gameInfo.tsx b/src/widgets/gameInfo/gameInfo.tsx deleted file mode 100644 index eba9752..0000000 --- a/src/widgets/gameInfo/gameInfo.tsx +++ /dev/null @@ -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(init_game); - - const { data: me } = useSWR("user", () => UserService.IdentifyYourself()); - const [editable, setEditable] = useState(false); - - useEffect(() => { - if (me) { - if (isExistingGame(game)) setEditable(me.id === game.owner_id); - else setEditable(true); - } - }, [me, game]); - - const formRef = useRef(null); - const { - register, - handleSubmit, - setValue, - watch, - reset, - formState: { dirtyFields, errors }, - } = useForm({ - 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( - null - ); - const watchedData = watch(); - const [formData, changeFormData] = useState(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(game.trailer); - - return ( -
- {(watchedData.cover || editable) && ( -
- {watchedData.cover && ( - - )} - {!watchedData.cover && editable && ( -
- )} - - {editable && ( -
- - - {isCoverDragActive ? ( -

Изменить обложку...

- ) : ( - <> - - Для редактирования нажмите или перетащите новую обложку - поверх старой - - - Для редактирования нажмите на обложку и выберите фото - - - )} -
-
- )} -
- )} - - - - Введите название - -

{ - setValue("title", e.currentTarget.innerText, { - shouldValidate: true, - shouldDirty: true, - }); - }} - > - {game.title} -

- - {editable && ( - - {savedTimeout && Object.keys(errors).length === 0 && ( - <> - - Редактируется - - )} - {savedTimeout && Object.keys(errors).length > 0 && ( - Некорректные данные - )} - {!savedTimeout && "Сохранено"} - - )} -
-
- {errors.title?.message} -
- {(game.description || editable) && ( - - - Введите описание - -
{ - setValue("description", e.currentTarget.innerText, { - shouldValidate: true, - shouldDirty: true, - }); - }} - > - {game.description} -
-
- )} -
-
- {( - [ - [ - { 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) => ( -
    - {section.map((req) => ( -
  • - {req.name + ": "} - - - Не известно - - { - setValue(req.key, e.currentTarget.innerText, { - shouldValidate: true, - shouldDirty: true, - }); - }} - > - {req.value ?? (game[req.key] as string)} - - -
  • - ))} -
- ))} -
- {(trailer || editable) && ( -
- {trailer && getYouTubeID(trailer) && ( -