Add audiobooks

This commit is contained in:
2024-06-15 11:53:03 +04:00
parent 1374c2bd70
commit f43dc5f11b
43 changed files with 1651 additions and 625 deletions

View File

@@ -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

View 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>
)}
</>
);
}

View 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>
)}
</>
);
}

View 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>
)}
</>
);
}

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
)}
</>
);
}

View File

@@ -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);

View File

@@ -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>
))}
</> </>
); );
} }

View File

@@ -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

View File

@@ -0,0 +1,4 @@
import { z } from "zod";
export const fragmentNameSchema = z.string().min(5);
export type FragmentNameType = z.infer<typeof fragmentNameSchema>;

View File

@@ -0,0 +1,4 @@
import { z } from "zod";
export const torrentNameSchema = z.string().min(5);
export type TorrentNameType = z.infer<typeof torrentNameSchema>;

View File

@@ -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: "",
};
}
}

View 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,
};

View 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" },
],
];
}

View 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;
});

View 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
);
};

View 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" },
],
];
}

View File

@@ -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
View 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
View 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
);
}
}

View 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" },
],
];
}

View 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;
});

View 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
);
};

View 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;

View File

@@ -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>
);
};

View File

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

View File

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

View 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>
);
};

View File

@@ -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);

View File

@@ -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>
);
};

View File

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

View File

@@ -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 = () => {

View File

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

View 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>
)}
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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>
)}
</>
);
};

View File

@@ -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"]