Add registration and data caching

This commit is contained in:
2024-06-15 18:25:29 +04:00
parent f43dc5f11b
commit 56a0841322
31 changed files with 477 additions and 202 deletions

View File

@@ -1,13 +1,14 @@
"use client"; "use client";
import { import {
LoginForm, LoginFormType,
loginFormFieldNames, loginFormFieldNames,
loginFormSchema, loginFormSchema,
} from "@/entities/user"; } from "@/entities/user";
import { UserService } from "@/entities/user/user"; import { UserService } from "@/entities/user/user";
import { Modal } from "@/shared/ui"; import { Modal } from "@/shared/ui";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { mutate } from "swr"; import { mutate } from "swr";
@@ -16,15 +17,17 @@ export default function Login() {
const { const {
register, register,
handleSubmit, handleSubmit,
setError,
formState: { errors }, formState: { errors },
} = useForm<LoginForm>({ resolver: zodResolver(loginFormSchema) }); } = useForm<LoginFormType>({ resolver: zodResolver(loginFormSchema) });
const router = useRouter(); const router = useRouter();
const onSubmit: SubmitHandler<LoginForm> = async (data) => { const onSubmit: SubmitHandler<LoginFormType> = async (data) => {
const userInfo = await UserService.Login(data); const userInfo = await UserService.Login(data);
mutate("user", userInfo); mutate("user", userInfo);
router.back(); if (userInfo) router.back();
else setError("root", { message: "Неверный логин или пароль" });
}; };
return ( return (
@@ -35,36 +38,46 @@ export default function Login() {
className="flex flex-col items-center justify-evenly" className="flex flex-col items-center justify-evenly"
> >
<h2 className="pb-4 text-4xl">.Torrent</h2> <h2 className="pb-4 text-4xl">.Torrent</h2>
{(["username", "password"] as (keyof LoginForm)[]).map((field) => ( {(["username", "password"] as (keyof LoginFormType)[]).map(
<label (field) => (
className="flex flex-col items-start relative w-64 py-1" <label
key={field} className="flex flex-col items-start relative w-64 py-1"
> key={field}
<input >
{...register(field)} <input
className="peer/search w-full rounded-lg bg-bg4 px-2 h-10" {...register(field)}
placeholder=" " className="peer/search w-full rounded-lg bg-bg4 px-2 h-10"
autoComplete="off" placeholder=" "
/> autoComplete="off"
<span />
className="peer-focus/search:opacity-0 <span
className="peer-focus/search:opacity-0
peer-[:not(:placeholder-shown)]/search:opacity-0 peer-[:not(:placeholder-shown)]/search:opacity-0
transition-opacity h-0 flex items-center relative bottom-5 left-4 transition-opacity h-0 flex items-center relative bottom-5 left-4
text-lg" text-lg"
> >
{loginFormFieldNames[field]} {loginFormFieldNames[field]}
</span> </span>
<p className="text-sm text-err w-full text-center"> <p className="text-sm text-err w-full text-center">
{errors[field]?.message} {errors[field]?.message}
</p> </p>
</label> </label>
))} )
)}
{errors.root && (
<p className="text-sm text-err w-full text-center">
{errors.root.message}
</p>
)}
<input <input
type="submit" type="submit"
value="Войти" value="Войти"
className="bg-ac0 mt-2 p-1 px-4 rounded-lg" className="bg-ac0 mt-2 p-1 px-4 rounded-lg"
/> />
<Link href="/registration" replace={true} className="text-xs pt-2">
Или <span className="hover:underline">создать аккаунт</span>
</Link>
</form> </form>
</div> </div>
</Modal> </Modal>

View File

@@ -0,0 +1,88 @@
"use client";
import {
RegistrationFormType,
registrationFormFieldNames,
RegistrationFormFields,
registrationFormSchema,
} from "@/entities/user";
import { UserService } from "@/entities/user/user";
import { Modal } from "@/shared/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SubmitHandler, useForm } from "react-hook-form";
import { mutate } from "swr";
export default function Registration() {
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<RegistrationFormType>({
resolver: zodResolver(registrationFormSchema),
});
const router = useRouter();
const onSubmit: SubmitHandler<RegistrationFormType> = async (data) => {
const userInfo = await UserService.Registration(data);
mutate("user", userInfo);
if (userInfo) router.back();
else setError("root", { message: "Некорректные данные" });
};
return (
<Modal>
<div className="">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col items-center justify-evenly"
>
<h2 className="pb-4 text-4xl">.Torrent</h2>
{(
Object.keys(registrationFormFieldNames) as RegistrationFormFields[]
).map((field) => (
<label
className="flex flex-col items-start relative w-64 py-1"
key={field}
>
<input
{...register(field)}
className="peer/search w-full rounded-lg bg-bg4 px-2 h-10"
placeholder=" "
autoComplete="off"
/>
<span
className="peer-focus/search:opacity-0
peer-[:not(:placeholder-shown)]/search:opacity-0
transition-opacity h-0 flex items-center relative bottom-5 left-4
text-lg"
>
{registrationFormFieldNames[field]}
</span>
<p className="text-sm text-err w-full text-center">
{errors[field]?.message}
</p>
</label>
))}
{errors.root && (
<p className="text-sm text-err w-full text-center">
{errors.root.message}
</p>
)}
<input
type="submit"
value="Зарегестрироваться"
className="bg-ac0 mt-2 p-1 px-4 rounded-lg"
/>
<Link href="/login" replace={true} className="text-xs pt-2">
Или <span className="hover:underline">войти</span>
</Link>
</form>
</div>
</Modal>
);
}

View File

@@ -3,6 +3,21 @@ import { ItemCard } from "@/features/itemCard";
import { ItemInfo } from "@/widgets/itemInfo"; import { ItemInfo } from "@/widgets/itemInfo";
import { Section } from "@/widgets/section"; import { Section } from "@/widgets/section";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Metadata } from "next";
export async function generateMetadata({
params: { section },
}: {
params: { section: string };
}): Promise<Metadata> {
if (!isSection(section)) {
redirect("/");
return {};
}
return {
title: `.Torrent: ${ItemService.itemSections[section].addItemText}`,
};
}
export default async function AddItem({ export default async function AddItem({
params: { section }, params: { section },

View File

@@ -2,11 +2,22 @@ import { isSection, ItemService, MovieService } from "@/entities/item";
import { ItemCard } from "@/features/itemCard"; import { ItemCard } from "@/features/itemCard";
import { Section } from "@/widgets/section"; import { Section } from "@/widgets/section";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Metadata } from "next";
//export const metadata: Metadata = { export async function generateMetadata({
// title: ".Torrent: Фильмы", params: { section },
// description: ".Torrent: Фильмы - каталог .torrent файлов для обмена фильмами", }: {
//}; params: { section: string };
}): Promise<Metadata> {
if (!isSection(section)) {
redirect("/");
return {};
}
return {
title: `.Torrent: ${ItemService.itemSections[section].sectionName}`,
description: `.Torrent: ${ItemService.itemSections[section].sectionName} - ${ItemService.itemSections[section].sectionName}`,
};
}
export default async function SectionPage({ export default async function SectionPage({
params: { section }, params: { section },

View File

@@ -7,36 +7,27 @@ export abstract class FilesService {
public static async UploadCover(cover: File) { public static async UploadCover(cover: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("cover", cover); formData.append("cover", cover);
return await HTTPService.post( return await HTTPService.post(`/files/cover`, coverNameSchema, {
`/files/cover`, body: formData,
coverNameSchema, stringify: false,
formData, });
{},
false
);
} }
public static async UploadTorrent(torrent: File) { public static async UploadTorrent(torrent: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("torrent", torrent); formData.append("torrent", torrent);
return await HTTPService.post( return await HTTPService.post(`/files/torrent`, torrentNameSchema, {
`/files/torrent`, body: formData,
torrentNameSchema, stringify: false,
formData, });
{},
false
);
} }
public static async UploadFragment(fragment: File) { public static async UploadFragment(fragment: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("fragment", fragment); formData.append("fragment", fragment);
return await HTTPService.post( return await HTTPService.post(`/files/audio`, fragmentNameSchema, {
`/files/audio`, body: formData,
fragmentNameSchema, stringify: false,
formData, });
{},
false
);
} }
} }

View File

@@ -1,4 +1,4 @@
import { HTTPService } from "@/shared/utils/http"; import { HTTPService, RequestCacheOptions } from "@/shared/utils/http";
import { audiobookCardsSchema } from "./schemas/audiobookCard"; import { audiobookCardsSchema } from "./schemas/audiobookCard";
import { import {
AudiobookCreateType, AudiobookCreateType,
@@ -17,17 +17,40 @@ import { ItemService } from "../item";
@staticImplements<IItemService>() @staticImplements<IItemService>()
export abstract class AudiobookService { export abstract class AudiobookService {
public static cacheTag = "all_audiobooks";
public static urlPrefix = "audiobooks";
private static cacheOptions(custom_tag?: string): RequestCacheOptions {
return {
next: {
tags: custom_tag ? [this.cacheTag, custom_tag] : [this.cacheTag],
revalidate: 60 * 5,
},
};
}
public static async GetCards() { public static async GetCards() {
return await HTTPService.get("/audiobooks/cards", audiobookCardsSchema); return await HTTPService.get(
`/${this.urlPrefix}/cards`,
audiobookCardsSchema,
this.cacheOptions(`/${this.urlPrefix}/cards`)
);
} }
public static async Get(id: number) { public static async Get(id: number) {
return await HTTPService.get(`/audiobooks/${id}`, audiobookSchema); return await HTTPService.get(
`/${this.urlPrefix}/${id}`,
audiobookSchema,
this.cacheOptions(`/${this.urlPrefix}/${id}`)
);
} }
public static async Add(info: AudiobookCreateType) { public static async Add(info: AudiobookCreateType) {
return await HTTPService.post(`/audiobooks`, audiobookSchema, info); return await HTTPService.post(`/${this.urlPrefix}`, audiobookSchema, {
body: info,
});
} }
public static async Change(id: number, info: AudiobookCreateType) { public static async Change(id: number, info: AudiobookCreateType) {
return await HTTPService.put(`/audiobooks/${id}`, audiobookSchema, info); return await HTTPService.put(`/${this.urlPrefix}/${id}`, audiobookSchema, {
body: info,
});
} }
public static GetEmpty(): AudiobookCreateType { public static GetEmpty(): AudiobookCreateType {

View File

@@ -44,14 +44,14 @@ export const audiobookSchema = audiobookBaseSchema.merge(
); );
export type AudiobookType = z.infer<typeof audiobookSchema>; export type AudiobookType = z.infer<typeof audiobookSchema>;
export const isAudiobook = (a: any): a is AudiobookType => { export const isAudiobookStrict = (a: any): a is AudiobookType => {
return audiobookSchema.safeParse(a).success; return audiobookSchema.safeParse(a).success;
}; };
export const audiobooksSchema = z.array(z.any()).transform((a) => { export const audiobooksSchema = z.array(z.any()).transform((a) => {
const audiobooks: AudiobookType[] = []; const audiobooks: AudiobookType[] = [];
a.forEach((e) => { a.forEach((e) => {
if (isAudiobook(e)) audiobooks.push(audiobookSchema.parse(e)); if (isAudiobookStrict(e)) audiobooks.push(audiobookSchema.parse(e));
else console.error("Audiobook parse error - ", e); else console.error("Audiobook parse error - ", e);
}); });
return audiobooks; return audiobooks;

View File

@@ -36,8 +36,5 @@ export const audiobookCardsSchema = z.array(z.any()).transform((a) => {
}); });
export const isAudiobook = (a: any): a is AudiobookCardType => { export const isAudiobook = (a: any): a is AudiobookCardType => {
return ( return (a as AudiobookCardType).type === TypesOfItems.audiobook;
audiobookCardBaseSchema.safeParse(a).success &&
(a as AudiobookCardType).type === TypesOfItems.audiobook
);
}; };

View File

@@ -1,4 +1,4 @@
import { HTTPService } from "@/shared/utils/http"; import { HTTPService, RequestCacheOptions } from "@/shared/utils/http";
import { gameCardsSchema } from "./schemas/gameCard"; import { gameCardsSchema } from "./schemas/gameCard";
import { GameCreateType, gameSchema, GameType } from "./schemas/game"; import { GameCreateType, gameSchema, GameType } from "./schemas/game";
import { import {
@@ -13,17 +13,40 @@ import { ItemService } from "../item";
@staticImplements<IItemService>() @staticImplements<IItemService>()
export abstract class GameService { export abstract class GameService {
public static cacheTag = "all_games";
public static urlPrefix = "games";
private static cacheOptions(custom_tag?: string): RequestCacheOptions {
return {
next: {
tags: custom_tag ? [this.cacheTag, custom_tag] : [this.cacheTag],
revalidate: 60 * 5,
},
};
}
public static async GetCards() { public static async GetCards() {
return await HTTPService.get("/games/cards", gameCardsSchema); return await HTTPService.get(
`/${this.urlPrefix}/cards`,
gameCardsSchema,
this.cacheOptions(`/${this.urlPrefix}/cards`)
);
} }
public static async Get(id: number) { public static async Get(id: number) {
return await HTTPService.get(`/games/${id}`, gameSchema); return await HTTPService.get(
`/${this.urlPrefix}/${id}`,
gameSchema,
this.cacheOptions(`/${this.urlPrefix}/${id}`)
);
} }
public static async Add(info: GameCreateType) { public static async Add(info: GameCreateType) {
return await HTTPService.post(`/games`, gameSchema, info); return await HTTPService.post(`/${this.urlPrefix}`, gameSchema, {
body: info,
});
} }
public static async Change(id: number, info: GameCreateType) { public static async Change(id: number, info: GameCreateType) {
return await HTTPService.put(`/games/${id}`, gameSchema, info); return await HTTPService.put(`/${this.urlPrefix}/${id}`, gameSchema, {
body: info,
});
} }
public static GetEmpty(): GameCreateType { public static GetEmpty(): GameCreateType {

View File

@@ -4,7 +4,7 @@ import { gameCardBaseSchema } from "./gameCard";
export const gameBaseSchema = gameCardBaseSchema.merge( export const gameBaseSchema = gameCardBaseSchema.merge(
z.object({ z.object({
torrent_file: z.string().min(3, "У раздачи должен быть .torrent файл"), torrent_file: z.string().min(3, "У раздачи должен быть .torrent файл"),
trailer: z.string().optional(), trailer: z.string().optional().nullable(),
system: z.string().optional().nullable(), system: z.string().optional().nullable(),
processor: z.string().optional().nullable(), processor: z.string().optional().nullable(),
@@ -49,14 +49,14 @@ export const gameSchema = gameBaseSchema.merge(
); );
export type GameType = z.infer<typeof gameSchema>; export type GameType = z.infer<typeof gameSchema>;
export const isGame = (a: any): a is GameType => { export const isGameStrict = (a: any): a is GameType => {
return gameSchema.safeParse(a).success; return gameSchema.safeParse(a).success;
}; };
export const gamesSchema = z.array(z.any()).transform((a) => { export const gamesSchema = z.array(z.any()).transform((a) => {
const games: GameType[] = []; const games: GameType[] = [];
a.forEach((e) => { a.forEach((e) => {
if (isGame(e)) games.push(gameSchema.parse(e)); if (isGameStrict(e)) games.push(gameSchema.parse(e));
else console.error("Game parse error - ", e); else console.error("Game parse error - ", e);
}); });
return games; return games;

View File

@@ -36,8 +36,5 @@ export const gameCardsSchema = z.array(z.any()).transform((a) => {
}); });
export const isGame = (a: any): a is GameCardType => { export const isGame = (a: any): a is GameCardType => {
return ( return (a as GameCardType).type === TypesOfItems.game;
gameCardBaseSchema.safeParse(a).success &&
(a as GameCardType).type === TypesOfItems.game
);
}; };

View File

@@ -1,13 +1,9 @@
import { ZodSchema } from "zod"; import { ZodSchema } from "zod";
import { gameCreateSchema, gameSchema } from "./game/schemas/game"; import { gameCreateSchema } from "./game/schemas/game";
import { movieCreateSchema, movieSchema } from "./movie/schemas/movie"; import { movieCreateSchema } from "./movie/schemas/movie";
import { MovieService } from "./movie/movie"; import { MovieService } from "./movie/movie";
import { HTTPService } from "@/shared/utils/http";
import { GameService } from "./game/game"; import { GameService } from "./game/game";
import { import { audiobookCreateSchema } from "./audiobook/schemas/audiobook";
audiobookCreateSchema,
audiobookSchema,
} from "./audiobook/schemas/audiobook";
import { AudiobookService } from "./audiobook/audiobook"; import { AudiobookService } from "./audiobook/audiobook";
import { import {
IItemService, IItemService,
@@ -19,6 +15,7 @@ import {
TypesOfItems, TypesOfItems,
UnionItemType, UnionItemType,
} from "./types"; } from "./types";
import { EraseCacheByTag } from "@/shared/utils/http";
export abstract class ItemService { export abstract class ItemService {
private static get itemsConfiguration(): { private static get itemsConfiguration(): {
@@ -26,11 +23,7 @@ export abstract class ItemService {
sectionUrl: ItemSectionsType; sectionUrl: ItemSectionsType;
formResolver: ZodSchema; formResolver: ZodSchema;
propertiesDescription: ItemPropertiesDescriptionType<UnionItemType>; propertiesDescription: ItemPropertiesDescriptionType<UnionItemType>;
AddItem: (itemInfo: ItemCreateType) => Promise<ItemType | null>; service: IItemService;
ChangeItem: (
id: number,
itemInfo: ItemCreateType
) => Promise<ItemType | null>;
}; };
} { } {
return { return {
@@ -38,57 +31,63 @@ export abstract class ItemService {
sectionUrl: "games", sectionUrl: "games",
formResolver: gameCreateSchema, formResolver: gameCreateSchema,
propertiesDescription: GameService.propertiesDescription, propertiesDescription: GameService.propertiesDescription,
AddItem: async (itemInfo) => service: GameService,
await HTTPService.post(`/games`, gameSchema, itemInfo),
ChangeItem: async (id: number, itemInfo) =>
await HTTPService.put(`/games/${id}`, gameSchema, itemInfo),
}, },
[TypesOfItems.movie]: { [TypesOfItems.movie]: {
sectionUrl: "movies", sectionUrl: "movies",
formResolver: movieCreateSchema, formResolver: movieCreateSchema,
propertiesDescription: MovieService.propertiesDescription, propertiesDescription: MovieService.propertiesDescription,
AddItem: async (itemInfo) => service: MovieService,
await HTTPService.post(`/movies`, movieSchema, itemInfo),
ChangeItem: async (id: number, itemInfo) =>
await HTTPService.put(`/movies/${id}`, movieSchema, itemInfo),
}, },
[TypesOfItems.audiobook]: { [TypesOfItems.audiobook]: {
sectionUrl: "audiobooks", sectionUrl: "audiobooks",
formResolver: audiobookCreateSchema, formResolver: audiobookCreateSchema,
propertiesDescription: AudiobookService.propertiesDescription, propertiesDescription: AudiobookService.propertiesDescription,
AddItem: async (itemInfo) => service: AudiobookService,
await HTTPService.post(`/audiobooks`, audiobookSchema, itemInfo),
ChangeItem: async (id: number, itemInfo) =>
await HTTPService.put(`/audiobooks/${id}`, audiobookSchema, itemInfo),
}, },
}; };
} }
static get itemSections(): { static get itemSections(): {
[k in ItemSectionsType]: { [k in ItemSectionsType]: {
sectionName: string;
itemType: TypesOfItems; itemType: TypesOfItems;
popularSubsectionName: string; popularSubsectionName: string;
sectionInviteText: string; sectionInviteText: string;
addItemText: string;
sectionDescription: string;
service: IItemService; service: IItemService;
}; };
} { } {
return { return {
games: { games: {
sectionName: "Игры",
itemType: TypesOfItems.game, itemType: TypesOfItems.game,
popularSubsectionName: "Популярные игры", popularSubsectionName: "Популярные игры",
sectionInviteText: 'Перейти в раздел "Игры"', sectionInviteText: 'Перейти в раздел "Игры"',
addItemText: "Добавить игру",
sectionDescription:
"каталог .torrent файлов для обмена актуальными версиями популярных игр",
service: GameService, service: GameService,
}, },
movies: { movies: {
sectionName: "Фильмы",
itemType: TypesOfItems.movie, itemType: TypesOfItems.movie,
popularSubsectionName: "Популярные фильмы", popularSubsectionName: "Популярные фильмы",
sectionInviteText: 'Перейти в раздел "Фильмы"', sectionInviteText: 'Перейти в раздел "Фильмы"',
addItemText: "Добавить фильм",
sectionDescription:
"каталог .torrent файлов для обмена популярными фильмами в лучшем качестве",
service: MovieService, service: MovieService,
}, },
audiobooks: { audiobooks: {
sectionName: "Аудиокниги",
itemType: TypesOfItems.audiobook, itemType: TypesOfItems.audiobook,
popularSubsectionName: "Популярные аудиокниги", popularSubsectionName: "Популярные аудиокниги",
sectionInviteText: 'Перейти в раздел "Аудиокниги"', sectionInviteText: 'Перейти в раздел "Аудиокниги"',
addItemText: "Добавить аудиокнигу",
sectionDescription:
"каталог .torrent файлов для обмена популярными аудиокнигами любимых авторов",
service: AudiobookService, service: AudiobookService,
}, },
}; };
@@ -120,12 +119,29 @@ export abstract class ItemService {
} }
public static async AddItem(itemInfo: ItemCreateType) { public static async AddItem(itemInfo: ItemCreateType) {
return await this.itemsConfiguration[itemInfo.type].AddItem(itemInfo); const item = await this.itemsConfiguration[itemInfo.type].service.Add(
itemInfo
);
if (item)
EraseCacheByTag(
`/${this.itemsConfiguration[itemInfo.type].service.urlPrefix}/${
item.id
}`
);
return item;
} }
public static async ChangeItem(id: number, itemInfo: ItemCreateType) { public static async ChangeItem(id: number, itemInfo: ItemCreateType) {
return await this.itemsConfiguration[itemInfo.type].ChangeItem( const item = await this.itemsConfiguration[itemInfo.type].service.Change(
id, id,
itemInfo itemInfo
); );
if (item)
EraseCacheByTag(
`/${this.itemsConfiguration[itemInfo.type].service.urlPrefix}/${
item.id
}`
);
return item;
} }
} }

View File

@@ -1,4 +1,4 @@
import { HTTPService } from "@/shared/utils/http"; import { HTTPService, RequestCacheOptions } from "@/shared/utils/http";
import { movieCardsSchema } from "./schemas/movieCard"; import { movieCardsSchema } from "./schemas/movieCard";
import { MovieCreateType, movieSchema, MovieType } from "./schemas/movie"; import { MovieCreateType, movieSchema, MovieType } from "./schemas/movie";
import { import {
@@ -13,17 +13,40 @@ import { ItemService } from "../item";
@staticImplements<IItemService>() @staticImplements<IItemService>()
export abstract class MovieService { export abstract class MovieService {
public static cacheTag = "all_movies";
public static urlPrefix = "movies";
private static cacheOptions(custom_tag?: string): RequestCacheOptions {
return {
next: {
tags: custom_tag ? [this.cacheTag, custom_tag] : [this.cacheTag],
revalidate: 60 * 5,
},
};
}
public static async GetCards() { public static async GetCards() {
return await HTTPService.get("/movies/cards", movieCardsSchema); return await HTTPService.get(
`/${this.urlPrefix}/cards`,
movieCardsSchema,
this.cacheOptions(`/${this.urlPrefix}/cards`)
);
} }
public static async Get(id: number) { public static async Get(id: number) {
return await HTTPService.get(`/movies/${id}`, movieSchema); return await HTTPService.get(
`/${this.urlPrefix}/${id}`,
movieSchema,
this.cacheOptions(`/${this.urlPrefix}/${id}`)
);
} }
public static async Add(info: MovieCreateType) { public static async Add(info: MovieCreateType) {
return await HTTPService.post(`/movies`, movieSchema, info); return await HTTPService.post(`/${this.urlPrefix}`, movieSchema, {
body: info,
});
} }
public static async Change(id: number, info: MovieCreateType) { public static async Change(id: number, info: MovieCreateType) {
return await HTTPService.put(`/movies/${id}`, movieSchema, info); return await HTTPService.put(`/${this.urlPrefix}/${id}`, movieSchema, {
body: info,
});
} }
public static GetEmpty(): MovieCreateType { public static GetEmpty(): MovieCreateType {

View File

@@ -4,7 +4,7 @@ import { movieCardBaseSchema } from "./movieCard";
export const movieBaseSchema = movieCardBaseSchema.merge( export const movieBaseSchema = movieCardBaseSchema.merge(
z.object({ z.object({
torrent_file: z.string().min(3, "У раздачи должен быть .torrent файл"), torrent_file: z.string().min(3, "У раздачи должен быть .torrent файл"),
trailer: z.string().optional(), trailer: z.string().optional().nullable(),
language: z.string().optional().nullable(), language: z.string().optional().nullable(),
subtitles: z.string().optional().nullable(), subtitles: z.string().optional().nullable(),
@@ -46,14 +46,14 @@ export const movieSchema = movieBaseSchema.merge(
); );
export type MovieType = z.infer<typeof movieSchema>; export type MovieType = z.infer<typeof movieSchema>;
export const isMovie = (a: any): a is MovieType => { export const isMovieStrict = (a: any): a is MovieType => {
return movieSchema.safeParse(a).success; return movieSchema.safeParse(a).success;
}; };
export const moviesSchema = z.array(z.any()).transform((a) => { export const moviesSchema = z.array(z.any()).transform((a) => {
const games: MovieType[] = []; const games: MovieType[] = [];
a.forEach((e) => { a.forEach((e) => {
if (isMovie(e)) games.push(movieSchema.parse(e)); if (isMovieStrict(e)) games.push(movieSchema.parse(e));
else console.error("Movie parse error - ", e); else console.error("Movie parse error - ", e);
}); });
return games; return games;

View File

@@ -36,8 +36,5 @@ export const movieCardsSchema = z.array(z.any()).transform((a) => {
}); });
export const isMovie = (a: any): a is MovieCardType => { export const isMovie = (a: any): a is MovieCardType => {
return ( return (a as MovieCardType).type === TypesOfItems.movie;
movieCardBaseSchema.safeParse(a).success &&
(a as MovieCardType).type === TypesOfItems.movie
);
}; };

View File

@@ -49,6 +49,8 @@ export type ItemPropertiesDescriptionType<T extends ItemType | ItemCreateType> =
}[][]; }[][];
export interface IItemService { export interface IItemService {
cacheTag: string;
urlPrefix: string;
GetCards(): Promise<ItemCardType[] | null>; GetCards(): Promise<ItemCardType[] | null>;
Get(id: number): Promise<ItemType | null>; Get(id: number): Promise<ItemType | null>;
Add(info: ItemCreateType): Promise<ItemType | null>; Add(info: ItemCreateType): Promise<ItemType | null>;

View File

@@ -1,16 +1,28 @@
import { import {
loginFormSchema, loginFormSchema,
loginFormFieldNames, loginFormFieldNames,
LoginForm, LoginFormType,
} from "./schemas/auth"; } from "./schemas/login";
import {
registrationFormSchema,
registrationFormFieldNames,
RegistrationFormType,
RegistrationFormFields,
} from "./schemas/registration";
import { userSchema, User } from "./schemas/user"; import { userSchema, User } from "./schemas/user";
import { UserService } from "./user"; import { UserService } from "./user";
export { export {
loginFormSchema, loginFormSchema,
loginFormFieldNames, loginFormFieldNames,
registrationFormSchema,
registrationFormFieldNames,
UserService, UserService,
userSchema, userSchema,
type User, type User,
type LoginForm, type LoginFormType,
type RegistrationFormType,
type RegistrationFormFields,
}; };

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export const loginFormSchema = z.object({
username: z.string().min(3, "Логин слишком короткий"),
password: z.string().min(3, "Пароль слишком короткий"),
});
export type LoginFormType = z.infer<typeof loginFormSchema>;
export type LoginFormFieldsType = keyof LoginFormType;
export const loginFormFieldNames: { [key in LoginFormFieldsType]: string } = {
username: "Логин",
password: "Пароль",
};

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
export const registrationFormSchema = z.object({
email: z
.string()
.min(3, "Почта слишком короткий")
.email("Это не адрес электронной почты"),
name: z.string().min(3, "Логин слишком короткий"),
password: z.string().min(3, "Пароль слишком короткий"),
});
export type RegistrationFormType = z.infer<typeof registrationFormSchema>;
export type RegistrationFormFields = keyof RegistrationFormType;
export const registrationFormFieldNames: {
[key in RegistrationFormFields]: string;
} = {
email: "E-mail",
name: "Логин",
password: "Пароль",
};

View File

@@ -1,16 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { userSchema } from "./user"; import { userSchema } from "./user";
export const loginFormSchema = z.object({
username: z.string().min(3, "Логин слишком короткий"),
password: z.string().min(3, "Пароль слишком короткий"),
});
export const loginFormFieldNames = {
username: "Логин",
password: "Пароль",
};
export type LoginForm = z.infer<typeof loginFormSchema>;
export const tokenResponseSchema = z export const tokenResponseSchema = z
.object({ .object({
access_token: z.string(), access_token: z.string(),

View File

@@ -1,35 +1,45 @@
import { HTTPService } from "@/shared/utils/http"; import { HTTPService } from "@/shared/utils/http";
import { import { LoginFormType } from "./schemas/login";
LoginForm,
TokenData,
tokenDataSchema,
TokenResponse,
tokenResponseSchema,
} from "./schemas/auth";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import {
TokenData,
tokenDataSchema,
tokenResponseSchema,
} from "./schemas/token";
import { RegistrationFormType } from "./schemas/registration";
export abstract class UserService { export abstract class UserService {
public static async Login(loginForm: LoginForm) { public static async Registration(registrationForm: RegistrationFormType) {
const accessToken = await HTTPService.post( const accessToken = await HTTPService.post(
"/auth", "/auth/registration",
tokenResponseSchema, tokenResponseSchema,
new URLSearchParams(Object.entries(loginForm)), { body: registrationForm }
{
"Content-Type": "application/x-www-form-urlencoded",
},
false
); );
if (accessToken) { return this.ProcessToken(accessToken);
const tokenData = this.DecodeToken(accessToken); }
public static async Login(loginForm: LoginFormType) {
const accessToken = await HTTPService.post("/auth", tokenResponseSchema, {
body: new URLSearchParams(Object.entries(loginForm)),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
stringify: false,
});
return this.ProcessToken(accessToken);
}
private static ProcessToken(token: string | null) {
if (token) {
const tokenData = this.DecodeToken(token);
if (tokenData) { if (tokenData) {
Cookies.set("access-token", accessToken, { Cookies.set("access-token", token, {
secure: true, secure: true,
expires: tokenData.expire, expires: tokenData.expire,
}); });
return tokenData; return tokenData;
} }
} }
return null;
} }
public static GetToken(): string | undefined { public static GetToken(): string | undefined {

View File

@@ -3,10 +3,11 @@
import { UserService } from "@/entities/user"; import { UserService } from "@/entities/user";
import { PersonIcon } from "@/shared/assets/icons"; import { PersonIcon } from "@/shared/assets/icons";
import Link from "next/link"; import Link from "next/link";
import useSWR from "swr"; import useSWR, { mutate } from "swr";
import clsx from "clsx"; import clsx from "clsx";
import Cookies from "js-cookie";
import { useState } from "react"; import { useState } from "react";
import { TokenData } from "@/entities/user/schemas/auth"; import { ItemService } from "@/entities/item";
export const UserActivities = () => { export const UserActivities = () => {
const { data: me } = useSWR("user", () => UserService.IdentifyYourself()); const { data: me } = useSWR("user", () => UserService.IdentifyYourself());
@@ -38,13 +39,22 @@ export const UserActivities = () => {
{[ {[
{ {
group: "Добавить:", group: "Добавить:",
items: [ items: Object.entries(ItemService.itemSections).map(
{ name: "Добавить игру", link: "/games/add" }, ([sectionId, section]) => {
{ name: "Добавить фильм", link: "/films/add" }, return {
{ name: "Добавить аудиокнигу", link: "/audiobooks/add" }, name: section.addItemText,
], link: `/${sectionId}/add`,
};
}
),
},
{
name: "Выйти",
onClick: () => {
Cookies.remove("access-token");
mutate("user", undefined);
},
}, },
{ name: "Выйти", link: "/logout" },
].map((item) => ( ].map((item) => (
<ul key={item.group ?? item.name}> <ul key={item.group ?? item.name}>
{item.group && ( {item.group && (
@@ -65,14 +75,14 @@ export const UserActivities = () => {
</li> </li>
</> </>
)} )}
{!item.group && item.link && ( {!item.group && (
<Link <span
key={item.name} key={item.name}
className="text-xl font-bold py-2 cursor-pointer hover:underline" className="text-xl font-bold py-2 cursor-pointer hover:underline"
href={item.link} onClick={item.onClick}
> >
{item.name} {item.name}
</Link> </span>
)} )}
</ul> </ul>
))} ))}

View File

@@ -1,30 +1,43 @@
import { UserService } from "@/entities/user"; import { UserService } from "@/entities/user";
import { z } from "zod"; import { z } from "zod";
type Body = BodyInit | object; export type RequestCacheOptions = {
cache?: RequestCache;
next?: NextFetchRequestConfig;
};
type GetRequestOptions = RequestCacheOptions & {
headers?: HeadersInit;
};
type RequestOptions = GetRequestOptions & {
body?: BodyInit | object;
stringify?: boolean;
};
export abstract class HTTPService { export abstract class HTTPService {
public static async request<Z extends z.ZodTypeAny>( public static async request<Z extends z.ZodTypeAny>(
method: "GET" | "POST" | "PUT" | "DELETE", method: "GET" | "POST" | "PUT" | "DELETE",
url: string, url: string,
schema: Z, schema: Z,
body?: Body, options?: RequestOptions
headers?: HeadersInit,
stringify?: boolean
) { ) {
return await fetch(process.env.NEXT_PUBLIC_BASE_URL + url, { return await fetch(process.env.NEXT_PUBLIC_BASE_URL + url, {
method: method, method: method,
headers: { headers: {
accept: "application/json", accept: "application/json",
...((stringify ?? true) != true ...((options?.stringify ?? true) != true
? {} ? {}
: { "Content-Type": "application/json" }), : { "Content-Type": "application/json" }),
Authorization: "Bearer " + UserService.GetToken(), Authorization: "Bearer " + UserService.GetToken(),
...headers, ...options?.headers,
}, },
body: body:
(stringify ?? true) != true ? (body as BodyInit) : JSON.stringify(body), (options?.stringify ?? true) != true
cache: "no-cache", ? (options?.body as BodyInit)
: JSON.stringify(options?.body),
cache: options?.cache ?? options?.next ? undefined : "no-cache",
next: options?.next ?? {},
}) })
.then((r) => { .then((r) => {
if (r && r.ok) return r; if (r && r.ok) return r;
@@ -42,27 +55,27 @@ export abstract class HTTPService {
}); });
} }
public static async get<Z extends z.ZodTypeAny>(url: string, schema: Z) { public static async get<Z extends z.ZodTypeAny>(
return await this.request<Z>("GET", url, schema); url: string,
schema: Z,
options?: GetRequestOptions
) {
return await this.request<Z>("GET", url, schema, options);
} }
public static async post<Z extends z.ZodTypeAny>( public static async post<Z extends z.ZodTypeAny>(
url: string, url: string,
schema: Z, schema: Z,
body?: Body, options?: RequestOptions
headers?: HeadersInit,
stringify?: boolean
) { ) {
return await this.request<Z>("POST", url, schema, body, headers, stringify); return await this.request<Z>("POST", url, schema, options);
} }
public static async put<Z extends z.ZodType>( public static async put<Z extends z.ZodType>(
url: string, url: string,
schema: Z, schema: Z,
body?: Body, options?: RequestOptions
headers?: HeadersInit,
stringify?: boolean
) { ) {
return await this.request<Z>("PUT", url, schema, body, headers, stringify); return await this.request<Z>("PUT", url, schema, options);
} }
} }

View File

@@ -0,0 +1,5 @@
import { HTTPService, type RequestCacheOptions } from "./http";
export { HTTPService, type RequestCacheOptions };
import { EraseCacheByTag, EraseCacheByTags } from "./revalidate";
export { EraseCacheByTag, EraseCacheByTags };

View File

@@ -0,0 +1,11 @@
"use server";
import { revalidateTag } from "next/cache";
export const EraseCacheByTag = (tag: string) => {
revalidateTag(tag);
};
export const EraseCacheByTags = (tags: string[]) => {
tags.forEach((tag) => EraseCacheByTag(tag));
};

View File

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

View File

@@ -7,12 +7,7 @@ import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation"; import { useSelectedLayoutSegment } from "next/navigation";
import clsx from "clsx"; import clsx from "clsx";
import { UserActivities } from "@/features/userActivities"; import { UserActivities } from "@/features/userActivities";
import { ItemSections, ItemService } from "@/entities/item";
const sections = [
{ title: "Игры", href: "/games" },
{ title: "Фильмы", href: "/movies" },
{ title: "Аудиокниги", href: "/audiobooks" },
];
export const Header = () => { export const Header = () => {
const currentPageName = useSelectedLayoutSegment(); const currentPageName = useSelectedLayoutSegment();
@@ -25,21 +20,21 @@ export const Header = () => {
> >
<h1 className="text-4xl font-bold flex items-center"> <h1 className="text-4xl font-bold flex items-center">
<div className="lp:hidden"> <div className="lp:hidden">
<MobileMenu sections={sections} /> <MobileMenu />
</div> </div>
<Link href="/">.Torrent</Link> <Link href="/">.Torrent</Link>
</h1> </h1>
<div className="hidden text-2xl lp:block"> <div className="hidden text-2xl lp:block">
{sections.map((section) => ( {ItemSections.map((section) => (
<Link <Link
key={section.title} key={section}
className={clsx( className={clsx(
"px-5 cursor-pointer hover:underline underline-offset-2", "px-5 cursor-pointer hover:underline underline-offset-2",
currentPageName === section.href && "underline" currentPageName === section && "underline"
)} )}
href={section.href} href={"/" + section}
> >
{section.title} {ItemService.itemSections[section].sectionName}
</Link> </Link>
))} ))}
</div> </div>

View File

@@ -1,14 +1,11 @@
"use client"; "use client";
import { ItemSections, ItemService } from "@/entities/item";
import clsx from "clsx"; import clsx from "clsx";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
export const MobileMenu = ({ export const MobileMenu = () => {
sections,
}: {
sections: { title: string; href: string }[];
}) => {
const [open, changeMenuOpen] = useState<boolean>(false); const [open, changeMenuOpen] = useState<boolean>(false);
return ( return (
@@ -35,13 +32,13 @@ export const MobileMenu = ({
)} )}
onClick={() => changeMenuOpen(false)} onClick={() => changeMenuOpen(false)}
> >
{sections.map((section) => ( {ItemSections.map((section) => (
<Link <Link
key={section.title} key={section}
className="text-xl py-2 cursor-pointer hover:underline" className="text-xl py-2 cursor-pointer hover:underline"
href={section.href} href={"/" + section}
> >
{section.title} {ItemService.itemSections[section].sectionName}
</Link> </Link>
))} ))}
</div> </div>

View File

@@ -1,6 +1,12 @@
"use client"; "use client";
import { isAudiobook, isGame, ItemCreateType, ItemType } from "@/entities/item"; import {
isAudiobook,
isGame,
isMovie,
ItemCreateType,
ItemType,
} from "@/entities/item";
import { UserService } from "@/entities/user"; import { UserService } from "@/entities/user";
import useSWR from "swr"; import useSWR from "swr";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -12,8 +18,8 @@ import { ItemProperties } from "./itemProperties";
import { ItemTrailer } from "./itemTrailer"; import { ItemTrailer } from "./itemTrailer";
import { ItemTorrent } from "./itemTorrent"; import { ItemTorrent } from "./itemTorrent";
import { ItemDetails } from "./itemDetails"; import { ItemDetails } from "./itemDetails";
import { isMovie } from "@/entities/item/movie/schemas/movie";
import { ItemFragment } from "./itemFragment"; import { ItemFragment } from "./itemFragment";
import { EraseCacheByTag } from "@/shared/utils/http";
export const ItemInfo = <T extends ItemType | ItemCreateType>({ export const ItemInfo = <T extends ItemType | ItemCreateType>({
item: init_item, item: init_item,

View File

@@ -93,8 +93,8 @@ export const ItemTorrent = ({
className="text-right text-sm relative top-4 lp:-top-4" className="text-right text-sm relative top-4 lp:-top-4"
href="/how_to_download" href="/how_to_download"
> >
Как скачать игру Как скачать с помощью
<br /> с помощью .torrent файла? <br /> .torrent файла?
</Link> </Link>
</div> </div>
</> </>

View File

@@ -1,5 +1,5 @@
import { ItemCreateType, ItemType } from "@/entities/item"; import { ItemCreateType, ItemType } from "@/entities/item";
import { getYouTubeID } from "@/shared/utils"; import { getYouTubeID } from "@/shared/utils/getYoutubeId";
import { UseFormRegister, UseFormSetValue } from "react-hook-form"; import { UseFormRegister, UseFormSetValue } from "react-hook-form";
export const ItemTrailer = ({ export const ItemTrailer = ({
@@ -9,8 +9,8 @@ export const ItemTrailer = ({
setFormValue: setValue, setFormValue: setValue,
registerFormField: register, registerFormField: register,
}: { }: {
trailer: string | undefined; trailer: string | undefined | null;
default_trailer: string | undefined; default_trailer: string | undefined | null;
editable: boolean; editable: boolean;
setFormValue: UseFormSetValue<ItemType | ItemCreateType>; setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
registerFormField: UseFormRegister<ItemType | ItemCreateType>; registerFormField: UseFormRegister<ItemType | ItemCreateType>;