Add genres and actors

This commit is contained in:
2024-07-06 20:09:02 +04:00
parent ea723b88b0
commit 05cfd9c955
34 changed files with 671 additions and 324 deletions

View File

@@ -9,9 +9,9 @@
- Next.js 14 (App Router)
- Tailwind CSS
- Zod
- React Hook Form
- SWR
- clsx
- React Hook Form
- и другие
- next-themes
- js-cookie

View File

@@ -46,7 +46,7 @@ export default function Login() {
>
<input
{...register(field)}
className="peer/search w-full rounded-lg bg-bg4 px-2 h-10"
className="peer/search w-full rounded-lg bg-bg4 px-2 h-10 outline-none"
placeholder=" "
autoComplete="off"
/>

View File

@@ -50,7 +50,7 @@ export default function Registration() {
>
<input
{...register(field)}
className="peer/search w-full rounded-lg bg-bg4 px-2 h-10"
className="peer/search w-full rounded-lg bg-bg4 px-2 h-10 outline-none"
placeholder=" "
autoComplete="off"
/>

View File

@@ -1,4 +1,4 @@
import { isSection, ItemService, MovieService } from "@/entities/item";
import { ItemService } from "@/entities/item";
import { ItemCard } from "@/widgets/itemCard";
import { Section } from "@/widgets/section";
import { redirect } from "next/navigation";

View File

@@ -14,6 +14,12 @@ import {
TypesOfItems,
} from "../types";
import { ItemService } from "../item";
import {
AudiobookGenreCreateType,
audiobookGenreSchema,
audiobookGenresSchema,
} from "./schemas/genre";
import { RequiredFrom } from "@/shared/utils/types";
@staticImplements<IItemService>()
export abstract class AudiobookService {
@@ -30,7 +36,7 @@ export abstract class AudiobookService {
public static async GetCards() {
return await HTTPService.get(
`/${this.urlPrefix}/cards`,
`/${this.urlPrefix}`,
audiobookCardsSchema,
this.cacheOptions(`/${this.urlPrefix}/cards`)
);
@@ -53,7 +59,7 @@ export abstract class AudiobookService {
});
}
public static GetEmpty(): AudiobookCreateType {
public static GetEmpty(): RequiredFrom<AudiobookCreateType> {
return {
title: "",
torrent_file: "",
@@ -61,6 +67,23 @@ export abstract class AudiobookService {
};
}
public static async GetGenres() {
return await HTTPService.get(
`/genres/${this.urlPrefix}`,
audiobookGenresSchema
);
}
public static async CreateGenre(info: AudiobookGenreCreateType) {
return await HTTPService.post(
`/genres/${this.urlPrefix}`,
audiobookGenreSchema,
{
body: info,
}
);
}
static propertiesDescription: ItemPropertiesDescriptionType<AudiobookType> = [
[
{ name: "Автор", key: "author" },

View File

@@ -1,5 +1,8 @@
import { z } from "zod";
import { audiobookCardBaseSchema } from "./audiobookCard";
import { audiobookGenresSchema } from "./genre";
import { ownerSchema } from "../../schemas/owner";
import { TypesOfItems } from "../../types";
export const audiobookBaseSchema = audiobookCardBaseSchema.merge(
z.object({
@@ -22,6 +25,8 @@ export const audiobookBaseSchema = audiobookCardBaseSchema.merge(
})
: undefined
),
genres: audiobookGenresSchema.optional().nullable(),
})
);
@@ -31,15 +36,11 @@ export type AudiobookCreateType = z.infer<typeof audiobookCreateSchema>;
export const audiobookSchema = audiobookBaseSchema.merge(
z.object({
id: z.number().positive(),
owner_id: z.number().positive(),
owner: ownerSchema,
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>;
@@ -56,3 +57,7 @@ export const audiobooksSchema = z.array(z.any()).transform((a) => {
});
return audiobooks;
});
export const isAudiobook = (a: any): a is AudiobookType => {
return (a as AudiobookType).type === TypesOfItems.audiobook;
};

View File

@@ -34,7 +34,3 @@ export const audiobookCardsSchema = z.array(z.any()).transform((a) => {
});
return cards;
});
export const isAudiobook = (a: any): a is AudiobookCardType => {
return (a as AudiobookCardType).type === TypesOfItems.audiobook;
};

View File

@@ -0,0 +1,32 @@
import { z } from "zod";
export const audiobookGenreBaseSchema = z.object({
genre: z.string().min(3),
});
export const audiobookGenreCreateSchema = audiobookGenreBaseSchema.merge(
z.object({})
);
export type AudiobookGenreCreateType = z.infer<
typeof audiobookGenreCreateSchema
>;
export const audiobookGenreSchema = audiobookGenreBaseSchema.merge(
z.object({
id: z.number().positive(),
})
);
export type AudiobookGenreType = z.infer<typeof audiobookGenreSchema>;
export const isAudiobookGenreStrict = (a: any): a is AudiobookGenreType => {
return audiobookGenreSchema.safeParse(a).success;
};
export const audiobookGenresSchema = z.array(z.any()).transform((a) => {
const cards: AudiobookGenreType[] = [];
a.forEach((e) => {
if (isAudiobookGenreStrict(e)) cards.push(audiobookGenreSchema.parse(e));
else console.error("AudiobookGenre parse error - ", e);
});
return cards;
});

View File

@@ -10,6 +10,12 @@ import {
TypesOfItems,
} from "../types";
import { ItemService } from "../item";
import {
GameGenreCreateType,
gameGenreSchema,
gameGenresSchema,
} from "./schemas/genre";
import { RequiredFrom } from "@/shared/utils/types";
@staticImplements<IItemService>()
export abstract class GameService {
@@ -26,7 +32,7 @@ export abstract class GameService {
public static async GetCards() {
return await HTTPService.get(
`/${this.urlPrefix}/cards`,
`/${this.urlPrefix}`,
gameCardsSchema,
this.cacheOptions(`/${this.urlPrefix}/cards`)
);
@@ -49,7 +55,7 @@ export abstract class GameService {
});
}
public static GetEmpty(): GameCreateType {
public static GetEmpty(): RequiredFrom<GameCreateType> {
return {
title: "",
torrent_file: "",
@@ -57,6 +63,20 @@ export abstract class GameService {
};
}
public static async GetGenres() {
return await HTTPService.get(`/genres/${this.urlPrefix}`, gameGenresSchema);
}
public static async CreateGenre(info: GameGenreCreateType) {
return await HTTPService.post(
`/genres/${this.urlPrefix}`,
gameGenreSchema,
{
body: info,
}
);
}
static propertiesDescription: ItemPropertiesDescriptionType<GameType> = [
[
{ name: "Система", key: "system" },

View File

@@ -1,5 +1,8 @@
import { z } from "zod";
import { gameCardBaseSchema } from "./gameCard";
import { gameGenresSchema } from "./genre";
import { ownerSchema } from "../../schemas/owner";
import { TypesOfItems } from "../../types";
export const gameBaseSchema = gameCardBaseSchema.merge(
z.object({
@@ -27,6 +30,8 @@ export const gameBaseSchema = gameCardBaseSchema.merge(
})
: undefined
),
genres: gameGenresSchema.optional().nullable(),
})
);
@@ -36,15 +41,11 @@ export type GameCreateType = z.infer<typeof gameCreateSchema>;
export const gameSchema = gameBaseSchema.merge(
z.object({
id: z.number().positive(),
owner_id: z.number().positive(),
owner: ownerSchema,
update_date: z
.string()
.min(1)
.transform((d) => new Date(d)),
upload_date: z
.string()
.min(1)
.transform((d) => new Date(d)),
})
);
export type GameType = z.infer<typeof gameSchema>;
@@ -61,3 +62,7 @@ export const gamesSchema = z.array(z.any()).transform((a) => {
});
return games;
});
export const isGame = (a: any): a is GameType => {
return (a as GameType).type === TypesOfItems.game;
};

View File

@@ -34,7 +34,3 @@ export const gameCardsSchema = z.array(z.any()).transform((a) => {
});
return cards;
});
export const isGame = (a: any): a is GameCardType => {
return (a as GameCardType).type === TypesOfItems.game;
};

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
export const gameGenreBaseSchema = z.object({
genre: z.string().min(3),
});
export const gameGenreCreateSchema = gameGenreBaseSchema.merge(z.object({}));
export type GameGenreCreateType = z.infer<typeof gameGenreCreateSchema>;
export const gameGenreSchema = gameGenreBaseSchema.merge(
z.object({
id: z.number().positive(),
})
);
export type GameGenreType = z.infer<typeof gameGenreSchema>;
export const isGameGenreStrict = (a: any): a is GameGenreType => {
return gameGenreSchema.safeParse(a).success;
};
export const gameGenresSchema = z.array(z.any()).transform((a) => {
const cards: GameGenreType[] = [];
a.forEach((e) => {
if (isGameGenreStrict(e)) cards.push(gameGenreSchema.parse(e));
else console.error("GameGenre parse error - ", e);
});
return cards;
});

View File

@@ -1,100 +1,37 @@
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 { isGame } from "./game/schemas/game";
export { isGame };
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 { isMovie } from "./movie/schemas/movie";
export { isMovie };
import {
audiobookCardSchema,
audiobookCardsSchema,
type AudiobookCardType,
isAudiobook,
} from "./audiobook/schemas/audiobookCard";
export {
audiobookCardSchema,
audiobookCardsSchema,
type AudiobookCardType,
isAudiobook,
};
import { AudiobookService } from "./audiobook/audiobook";
export { AudiobookService };
import { isAudiobook } from "./audiobook/schemas/audiobook";
export { isAudiobook };
import { ItemService } from "./item";
export { ItemService };
import {
TypesOfItems,
isGenre,
type IItemService,
type ItemType,
type ItemCardType,
type ItemCreateType,
type GenreType,
type CreateGenreType,
type ItemListPropertyType,
} from "./types";
export {
TypesOfItems,
isGenre,
type IItemService,
type ItemType,
type ItemCardType,
type ItemCreateType,
type GenreType,
type CreateGenreType,
type ItemListPropertyType,
};

View File

@@ -10,6 +10,17 @@ import {
TypesOfItems,
} from "../types";
import { ItemService } from "../item";
import {
MovieGenreCreateType,
movieGenreSchema,
movieGenresSchema,
} from "./schemas/genre";
import {
MovieActorCreateType,
movieActorSchema,
movieActorsSchema,
} from "./schemas/actors";
import { RequiredFrom } from "@/shared/utils/types";
@staticImplements<IItemService>()
export abstract class MovieService {
@@ -26,7 +37,7 @@ export abstract class MovieService {
public static async GetCards() {
return await HTTPService.get(
`/${this.urlPrefix}/cards`,
`/${this.urlPrefix}`,
movieCardsSchema,
this.cacheOptions(`/${this.urlPrefix}/cards`)
);
@@ -49,7 +60,7 @@ export abstract class MovieService {
});
}
public static GetEmpty(): MovieCreateType {
public static GetEmpty(): RequiredFrom<MovieCreateType> {
return {
title: "",
torrent_file: "",
@@ -57,6 +68,33 @@ export abstract class MovieService {
};
}
public static async GetGenres() {
return await HTTPService.get(
`/genres/${this.urlPrefix}`,
movieGenresSchema
);
}
public static async CreateGenre(info: MovieGenreCreateType) {
return await HTTPService.post(
`/genres/${this.urlPrefix}`,
movieGenreSchema,
{
body: info,
}
);
}
public static async GetActors() {
return await HTTPService.get(`/actors`, movieActorsSchema);
}
public static async CreateActor(info: MovieActorCreateType) {
return await HTTPService.post(`/actors`, movieActorSchema, {
body: info,
});
}
public static propertiesDescription: ItemPropertiesDescriptionType<MovieType> =
[
[

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
export const movieActorBaseSchema = z.object({
actor: z.string().min(3),
});
export const movieActorCreateSchema = movieActorBaseSchema.merge(z.object({}));
export type MovieActorCreateType = z.infer<typeof movieActorCreateSchema>;
export const movieActorSchema = movieActorBaseSchema.merge(
z.object({
id: z.number().positive(),
})
);
export type MovieActorType = z.infer<typeof movieActorSchema>;
export const isMovieActorStrict = (a: any): a is MovieActorType => {
return movieActorSchema.safeParse(a).success;
};
export const movieActorsSchema = z.array(z.any()).transform((a) => {
const cards: MovieActorType[] = [];
a.forEach((e) => {
if (isMovieActorStrict(e)) cards.push(movieActorSchema.parse(e));
else console.error("MovieActor parse error - ", e);
});
return cards;
});

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
export const movieGenreBaseSchema = z.object({
genre: z.string().min(3),
});
export const movieGenreCreateSchema = movieGenreBaseSchema.merge(z.object({}));
export type MovieGenreCreateType = z.infer<typeof movieGenreCreateSchema>;
export const movieGenreSchema = movieGenreBaseSchema.merge(
z.object({
id: z.number().positive(),
})
);
export type MovieGenreType = z.infer<typeof movieGenreSchema>;
export const isMovieGenreStrict = (a: any): a is MovieGenreType => {
return movieGenreSchema.safeParse(a).success;
};
export const movieGenresSchema = z.array(z.any()).transform((a) => {
const cards: MovieGenreType[] = [];
a.forEach((e) => {
if (isMovieGenreStrict(e)) cards.push(movieGenreSchema.parse(e));
else console.error("MovieGenre parse error - ", e);
});
return cards;
});

View File

@@ -1,5 +1,9 @@
import { z } from "zod";
import { movieCardBaseSchema } from "./movieCard";
import { movieGenresSchema } from "./genre";
import { movieActorsSchema } from "./actors";
import { ownerSchema } from "../../schemas/owner";
import { TypesOfItems } from "../../types";
export const movieBaseSchema = movieCardBaseSchema.merge(
z.object({
@@ -24,6 +28,9 @@ export const movieBaseSchema = movieCardBaseSchema.merge(
})
: undefined
),
actors: movieActorsSchema.optional().nullable(),
genres: movieGenresSchema.optional().nullable(),
})
);
@@ -33,15 +40,11 @@ export type MovieCreateType = z.infer<typeof movieCreateSchema>;
export const movieSchema = movieBaseSchema.merge(
z.object({
id: z.number().positive(),
owner_id: z.number().positive(),
owner: ownerSchema,
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>;
@@ -58,3 +61,7 @@ export const moviesSchema = z.array(z.any()).transform((a) => {
});
return games;
});
export const isMovie = (a: any): a is MovieType => {
return (a as MovieType).type === TypesOfItems.movie;
};

View File

@@ -34,7 +34,3 @@ export const movieCardsSchema = z.array(z.any()).transform((a) => {
});
return cards;
});
export const isMovie = (a: any): a is MovieCardType => {
return (a as MovieCardType).type === TypesOfItems.movie;
};

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const ownerSchema = z.object({
id: z.number().positive(),
name: z.string().min(3),
email: z.string().min(3),
});

View File

@@ -7,6 +7,13 @@ import {
AudiobookType,
} from "./audiobook/schemas/audiobook";
import { AudiobookCardType } from "./audiobook/schemas/audiobookCard";
import { GameGenreCreateType, GameGenreType } from "./game/schemas/genre";
import { MovieGenreCreateType, MovieGenreType } from "./movie/schemas/genre";
import {
AudiobookGenreCreateType,
AudiobookGenreType,
} from "./audiobook/schemas/genre";
import { MovieActorType } from "./movie/schemas/actors";
export type ItemType = GameType | MovieType | AudiobookType;
export type ItemCardType = GameCardType | MovieCardType | AudiobookCardType;
@@ -14,9 +21,20 @@ export type ItemCreateType =
| GameCreateType
| MovieCreateType
| AudiobookCreateType;
export type UnionItemType = GameType & MovieType & AudiobookType;
export type GenreType = GameGenreType | MovieGenreType | AudiobookGenreType;
export type CreateGenreType =
| GameGenreCreateType
| MovieGenreCreateType
| AudiobookGenreCreateType;
export type ItemListPropertyType = GenreType | MovieActorType;
export const isGenre = (g: any): g is GenreType => {
return (g as GenreType).genre !== undefined;
};
export enum TypesOfItems {
game,
movie,
@@ -39,6 +57,8 @@ export interface IItemService {
Change(id: number, info: ItemCreateType): Promise<ItemType | null>;
GetEmpty(): ItemCreateType;
propertiesDescription: ItemPropertiesDescriptionType<UnionItemType>;
CreateGenre(info: CreateGenreType): Promise<GenreType | null>;
GetGenres(): Promise<GenreType[] | null>;
}
export const staticImplements =
<T>() =>

View File

@@ -0,0 +1,30 @@
export const CrossIcon = ({ className }: { className?: string }) => {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<path
d="M6.99486 7.00636C6.60433 7.39689 6.60433 8.03005 6.99486 8.42058L10.58
12.0057L6.99486 15.5909C6.60433 15.9814 6.60433 16.6146 6.99486
17.0051C7.38538 17.3956 8.01855 17.3956 8.40907 17.0051L11.9942
13.4199L15.5794 17.0051C15.9699 17.3956 16.6031 17.3956 16.9936
17.0051C17.3841 16.6146 17.3841 15.9814 16.9936 15.5909L13.4084
12.0057L16.9936 8.42059C17.3841 8.03007 17.3841 7.3969 16.9936
7.00638C16.603 6.61585 15.9699 6.61585 15.5794 7.00638L11.9942
10.5915L8.40907 7.00636C8.01855 6.61584 7.38538 6.61584 6.99486 7.00636Z"
fill="var(--color-bg1)"
></path>{" "}
</g>
</svg>
);
};

View File

@@ -2,5 +2,6 @@ import { SearchIcon } from "./searchIcon";
import { PersonIcon } from "./personIcon";
import { SunIcon } from "./sunIcon";
import { SpinnerIcon } from "./spinnerIcon";
import { CrossIcon } from "./cross";
export { SearchIcon, PersonIcon, SunIcon, SpinnerIcon };
export { SearchIcon, PersonIcon, SunIcon, SpinnerIcon, CrossIcon };

View File

@@ -8,7 +8,7 @@ export const Img = ({
alt,
className,
}: {
src: string;
src: string | null | undefined;
preview?: boolean;
width?: number;
height?: number;
@@ -16,18 +16,22 @@ export const Img = ({
className: string;
}) => {
return (
<Image
className={className}
src={
(preview
? process.env.NEXT_PUBLIC_COVER_PREVIEW_URL
: process.env.NEXT_PUBLIC_COVER_FULL_URL) +
"/" +
src
}
width={width}
height={height}
alt={alt ?? ""}
/>
<>
{src && (
<Image
className={className}
src={
(preview
? process.env.NEXT_PUBLIC_COVER_PREVIEW_URL
: process.env.NEXT_PUBLIC_COVER_FULL_URL) +
"/" +
src
}
width={width}
height={height}
alt={alt ?? ""}
/>
)}
</>
);
};

View File

@@ -17,6 +17,7 @@ type RequestOptions = GetRequestOptions & {
export abstract class HTTPService {
private static deepUndefinedToNull(o?: object): object | undefined {
if (Array.isArray(o)) return o;
if (o)
return Object.fromEntries(
Object.entries(o).map(([k, v]) => {

View File

@@ -0,0 +1,7 @@
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
export type RequiredFrom<T> = {
[K in RequiredKeys<T>]-?: T[K];
};

View File

@@ -13,7 +13,10 @@ export const MobileMenu = () => {
<button
className="w-16 h-16 *:w-12 *:h-1 *:bg-fg1 *:my-3
*:transition-all *:duration-300 *:relative"
onClick={() => changeMenuOpen(!open)}
onClick={(e) => {
changeMenuOpen(!open);
e.stopPropagation();
}}
onBlur={() => changeMenuOpen(false)}
>
<div

View File

@@ -1,19 +1,25 @@
import { FilesService } from "@/entities/files";
import { ItemCreateType, ItemType } from "@/entities/item";
import { ItemCreateType } from "@/entities/item";
import { Img } from "@/shared/ui";
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { useDropzone } from "react-dropzone";
import { UseFormSetValue } from "react-hook-form";
import { useFormContext } from "react-hook-form";
export const ItemCover = ({
cover,
editable,
setFormValue: setValue,
}: {
cover: string | null | undefined;
editable: boolean;
setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
}) => {
const { register, setValue, watch } = useFormContext<ItemCreateType>();
const watch_cover = watch("cover");
useEffect(() => {
register("cover", { value: cover });
}, [cover, register]);
const onCoverDrop = useCallback(
(acceptedFiles: File[]) => {
const file = acceptedFiles[0];
@@ -48,18 +54,16 @@ export const ItemCover = ({
return (
<>
{(cover || editable) && (
{(cover || watch_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 && (
<Img
src={watch_cover ?? cover}
preview={false}
className="transition-all rounded-lg w-full object-contain"
width={1280}
height={720}
/>
{!watch_cover && !cover && editable && (
<div className="w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
)}

View File

@@ -1,5 +1,9 @@
import { ItemCreateType, ItemType } from "@/entities/item";
import { UseFormRegister, UseFormSetValue } from "react-hook-form";
import {
useFormContext,
UseFormRegister,
UseFormSetValue,
} from "react-hook-form";
import clsx from "clsx";
import { SpinnerIcon } from "@/shared/assets/icons";
@@ -8,23 +12,22 @@ export const ItemDetails = ({
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;
};
title: string;
description: string | null | undefined;
editable: boolean;
state: "saved" | "editing" | "error";
registerFormField: UseFormRegister<ItemType | ItemCreateType>;
setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
}) => {
const {
register,
setValue,
watch,
formState: { errors },
} = useFormContext<ItemCreateType>();
const watched_title = watch("title");
const watched_description = watch("description");
return (
<span>
<span className="flex items-end justify-between relative pt-2">
@@ -32,7 +35,7 @@ export const ItemDetails = ({
<span
className={clsx(
"text-fg4 text-2xl absolute -z-10 opacity-0",
title.title === "" && "opacity-100",
watched_title === "" && "opacity-100",
"transition-opacity cursor-text"
)}
>
@@ -46,7 +49,7 @@ export const ItemDetails = ({
)}
suppressContentEditableWarning={true}
contentEditable={editable}
{...register("title", { value: title.default_title })}
{...register("title", { value: title })}
onInput={(e) => {
setValue("title", e.currentTarget.innerText, {
shouldValidate: true,
@@ -54,7 +57,7 @@ export const ItemDetails = ({
});
}}
>
{title.default_title}
{title}
</h1>
{editable && (
@@ -73,14 +76,14 @@ export const ItemDetails = ({
</span>
)}
</span>
<div className="text-err text-xs w-full h-2">{title.error}</div>
{(description.default_description || editable) && (
<div className="text-err text-xs w-full h-2">{errors.title?.message}</div>
{(description || editable) && (
<span className="relative">
{editable && (
<span
className={clsx(
"text-fg4 text-md absolute -z-10 opacity-0",
(description.description === "" || description === undefined) &&
(watched_description === "" || description === undefined) &&
"opacity-100",
"transition-opacity mt-2"
)}
@@ -97,7 +100,7 @@ export const ItemDetails = ({
!editable && "cursor-default"
)}
{...register("description", {
value: description.default_description,
value: description,
})}
onInput={(e) => {
setValue("description", e.currentTarget.innerText, {
@@ -106,7 +109,7 @@ export const ItemDetails = ({
});
}}
>
{description.default_description}
{description}
</div>
</span>
)}

View File

@@ -1,21 +1,25 @@
import { FilesService } from "@/entities/files";
import { ItemCreateType, ItemType } from "@/entities/item";
import { useCallback } from "react";
import { ItemCreateType } from "@/entities/item";
import { useCallback, useEffect } from "react";
import { useDropzone } from "react-dropzone";
import { UseFormRegister, UseFormSetValue } from "react-hook-form";
import { useFormContext } 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 { register, setValue, watch } = useFormContext<ItemCreateType>();
const watched_fragment = watch("fragment");
useEffect(() => {
register("fragment", { value: fragment });
}, [fragment, register]);
const onFragmentDrop = useCallback(
(acceptedFiles: File[]) => {
const file = acceptedFiles[0];
@@ -57,8 +61,15 @@ export const ItemFragment = ({
controls
controlsList="nodownload"
typeof="audio/mpeg"
src={process.env.NEXT_PUBLIC_FRAGMENT_URL + "/" + (fragment ?? "")}
className={clsx(!fragment && "pointer-events-none", "w-full h-full")}
src={
process.env.NEXT_PUBLIC_FRAGMENT_URL +
"/" +
(watched_fragment ?? "")
}
className={clsx(
!watched_fragment && "pointer-events-none",
"w-full h-full"
)}
/>
{editable && (
<>

View File

@@ -6,22 +6,24 @@ import {
isMovie,
ItemCreateType,
ItemType,
MovieService,
} 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 { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ItemCover } from "./itemCover";
import { ItemService } from "@/entities/item/item";
import { ItemService as IS } from "@/entities/item/item";
import { ItemProperties } from "./itemProperties";
import { ItemTrailer } from "./itemTrailer";
import { ItemTorrent } from "./itemTorrent";
import { ItemDetails } from "./itemDetails";
import { ItemFragment } from "./itemFragment";
import { EraseCacheByTag } from "@/shared/utils/http";
import { ItemListProperties } from "./itemListProperties";
import { RequiredFrom } from "@/shared/utils/types";
export const ItemInfo = <T extends ItemType | ItemCreateType>({
export const ItemInfo = <T extends ItemType | RequiredFrom<ItemCreateType>>({
item: init_item,
}: {
item: T;
@@ -33,140 +35,118 @@ export const ItemInfo = <T extends ItemType | ItemCreateType>({
useEffect(() => {
if (me) {
if (ItemService.isExistingItem(item))
setEditable(me.id === item.owner_id);
if (IS.isExistingItem(item)) setEditable(me.id === item.owner.id);
else setEditable(true);
}
}, [me, item]);
const formRef = useRef<HTMLFormElement>(null);
const {
register,
handleSubmit,
setValue,
setError,
watch,
reset,
formState: { dirtyFields, errors },
} = useForm<ItemType | ItemCreateType>({
const form = useForm<ItemCreateType>({
defaultValues: init_item,
resolver: zodResolver(
ItemService.itemsConfiguration[item.type].formResolver
),
resolver: zodResolver(IS.itemsConfiguration[item.type].formResolver),
});
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 watch = form.watch();
const [formData, changeFormData] = useState<T | null>(null);
useEffect(() => {
if (!Object.keys(dirtyFields).length) return;
if (JSON.stringify(watchedData) === JSON.stringify(formData)) return;
changeFormData(watchedData as T);
if (!Object.keys(form.formState.dirtyFields).length) return;
if (JSON.stringify(watch) === JSON.stringify(formData)) return;
changeFormData(watch as T);
if (savedTimeout) clearTimeout(savedTimeout);
changeSavedTimeout(
setTimeout(() => {
if (formRef.current) formRef.current.requestSubmit();
}, 3000)
);
}, [watchedData]);
}, [watch]);
const onSubmit = async (formData: ItemCreateType) => {
const updatedItem = ItemService.isExistingItem(item)
? await ItemService.ChangeItem(item.id, formData)
: await ItemService.AddItem(formData);
const updatedItem = IS.isExistingItem(item)
? await IS.ChangeItem(item.id, formData)
: await IS.AddItem(formData);
changeSavedTimeout(null);
if (updatedItem) {
changeItem(updatedItem as T);
reset({}, { keepValues: true });
form.reset({}, { keepValues: true });
} else {
setError("root", { message: "Ошибка сервера" });
form.setError("root", { message: "Ошибка сервера" });
}
};
useEffect(() => console.log(errors), [errors]);
useEffect(() => console.log(form.formState.errors), [form.formState.errors]);
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="m-4 flex flex-col lp:block"
ref={formRef}
>
<ItemCover
cover={watchedData.cover}
editable={editable}
setFormValue={setValue}
/>
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="m-4 flex flex-col lp:block"
ref={formRef}
>
<ItemCover
cover={IS.isExistingItem(item) ? item.cover : null}
editable={editable}
/>
<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}
/>
<ItemDetails
title={item.title}
description={IS.isExistingItem(item) ? item.description : null}
editable={editable}
state={
savedTimeout
? Object.keys(form.formState.errors).length > 0
? "error"
: "editing"
: "saved"
}
/>
<ItemProperties
item={item}
watchedFormData={watchedData}
editable={editable}
setFormValue={setValue}
registerFormField={register}
/>
<ItemListProperties
propertyName="genres"
propertyList={IS.isExistingItem(item) ? item.genres : null}
editable={editable}
getAllTags={async () =>
await IS.itemsConfiguration[item.type].service.GetGenres()
}
createTag={async (property: string) =>
await IS.itemsConfiguration[item.type].service.CreateGenre({
genre: property,
})
}
/>
{(isGame(item) || isMovie(item)) &&
(isGame(watchedData) || isMovie(watchedData)) && (
<ItemTrailer
default_trailer={item.trailer}
trailer={watchedData.trailer}
<ItemProperties item={item} editable={editable} />
{isMovie(item) && (
<ItemListProperties
propertyName="actors"
propertyList={IS.isExistingItem(item) ? item.actors : null}
editable={editable}
registerFormField={register}
setFormValue={setValue}
getAllTags={async () => await MovieService.GetActors()}
createTag={async (property: string) =>
await MovieService.CreateActor({ actor: property })
}
/>
)}
{isAudiobook(watchedData) && (
<ItemFragment
fragment={watchedData.fragment}
editable={editable}
registerFormField={register}
setFormValue={setValue}
/>
)}
{((isGame(item) && isGame(watch)) ||
(isMovie(item) && isMovie(watch))) && (
<ItemTrailer default_trailer={item.trailer} editable={editable} />
)}
<ItemTorrent
title={watchedData.title}
torrent_file={watchedData.torrent_file}
editable={editable}
error={errors.torrent_file?.message}
setFormValue={setValue}
/>
{isAudiobook(item) && isAudiobook(watch) && (
<ItemFragment fragment={item.fragment} editable={editable} />
)}
<input type="submit" className="hidden" />
</form>
<ItemTorrent torrent_file={item.torrent_file} editable={editable} />
<input type="submit" className="hidden" />
</form>
</FormProvider>
);
};

View File

@@ -0,0 +1,126 @@
import { isGenre, ItemCreateType, ItemListPropertyType } from "@/entities/item";
import { CrossIcon } from "@/shared/assets/icons";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { FieldArrayPath, useFieldArray, useFormContext } from "react-hook-form";
export const ItemListProperties = <T extends ItemListPropertyType>({
propertyName,
editable,
getAllTags,
createTag,
}: {
propertyName: FieldArrayPath<ItemCreateType>;
propertyList: T[] | null | undefined;
editable: boolean;
getAllTags: () => Promise<T[] | null>;
createTag: (property: string) => Promise<T | null>;
}) => {
const { watch } = useFormContext<ItemCreateType>();
const watched_property = watch(propertyName);
const { fields, append, replace, remove } = useFieldArray<ItemCreateType>({
name: propertyName,
});
const [allTags, setAllTags] = useState<T[] | null>();
const [searchTags, setSearchTags] = useState<T[] | null>();
const [addition, changeAdditionState] = useState<boolean>(false);
const getTagValue = (tag: T) => (isGenre(tag) ? tag.genre : tag.actor);
const getTagsByQuery = (query: string) =>
allTags
?.filter((t) => getTagValue(t).includes(query))
.filter((t) => !watched_property?.map((tag) => tag.id).includes(t.id));
const [searchField, changeSearchField] = useState<string>("");
useEffect(() => setSearchTags(getTagsByQuery(searchField)), [searchField]);
useEffect(
() => console.log(fields, watched_property),
[fields, watched_property]
);
return (
<span>
<div className="flex items-start justify-start p-2 flex-wrap">
{fields.map((field, i) => (
<div
className="px-2 py-1 bg-bg1 rounded-lg m-1 relative group cursor-default"
key={field.id}
>
{watched_property && (
<span>{getTagValue(watched_property[i] as T)}</span>
)}
{editable && (
<button
className="opacity-0 group-hover:opacity-100 transition-opacity
cursor-pointer absolute -top-1 -right-1 rounded-full bg-bg4 w-4 h-4"
onClick={() => remove(i)}
>
<CrossIcon />
</button>
)}
</div>
))}
{editable && (
<>
<button
className={clsx(
"px-2 py-1 bg-bg1 rounded-lg m-1 cursor-pointer transition-opacity",
addition && "opacity-0"
)}
onClick={async () => {
changeAdditionState(editable);
setAllTags(await getAllTags());
}}
>
+
</button>
<div
className={clsx(
"px-2 py-1 bg-bg1 rounded-lg m-1 opacity-0 transition-all relative",
!addition && "w-0 h-0 overflow-hidden",
addition && "opacity-100 !h-fit"
)}
>
<input
className="w-24 outline-none"
onChange={(e) => changeSearchField(e.target.value)}
onKeyDown={async (e) => {
if (e.key === "Enter") {
const tag = await createTag(searchField);
if (tag) replace([...(watched_property ?? []), tag]);
changeAdditionState(false);
}
}}
></input>
<div
className={clsx(
"static w-28 bg-bg1 top-0 left-0 px-2 opacity-0",
"transition-opacity rounded-b-lg",
searchTags && "opacity-100"
)}
>
{searchTags?.map((tag) => (
<div
key={tag.id}
className="w-full py-1 cursor-pointer overflow-hidden text-ellipsis"
onClick={() => {
append(tag);
console.log("updated");
changeAdditionState(false);
}}
>
{getTagValue(tag)}
</div>
))}
</div>
</div>
</>
)}
</div>
</span>
);
};

View File

@@ -1,22 +1,27 @@
import { ItemCreateType, ItemType } from "@/entities/item";
import { ItemService } from "@/entities/item/item";
import { ItemPropertiesDescriptionType } from "@/entities/item/types";
import { RequiredFrom } from "@/shared/utils/types";
import clsx from "clsx";
import { UseFormRegister, UseFormSetValue } from "react-hook-form";
import {
useFormContext,
UseFormRegister,
UseFormSetValue,
} from "react-hook-form";
export const ItemProperties = <T extends ItemType | ItemCreateType>({
export const ItemProperties = <
T extends ItemType | RequiredFrom<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>;
}) => {
const { register, setValue, watch } = useFormContext<ItemCreateType>();
const watchedData = watch();
return (
<div
className={clsx(
@@ -26,7 +31,7 @@ export const ItemProperties = <T extends ItemType | ItemCreateType>({
>
{(
ItemService.itemsConfiguration[item.type]
.propertiesDescription as ItemPropertiesDescriptionType<T>
.propertiesDescription as ItemPropertiesDescriptionType<ItemCreateType>
).map((section, i) => (
<ul key={i} className="w-[48%] bg-bg1 rounded-lg py-1 px-4">
{section.map((req) => (
@@ -54,27 +59,25 @@ export const ItemProperties = <T extends ItemType | ItemCreateType>({
(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, {
{...register(req.key, {
value: req.value
? req.value(item)
: undefined ?? (item[req.key] as string),
? req.value(item as ItemCreateType)
: undefined ??
((item as ItemCreateType)[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,
}
);
setValue(req.key, e.currentTarget.innerText, {
shouldValidate: true,
shouldDirty: true,
});
}}
>
{req.value
? req.value(item)
: undefined ?? (item[req.key] as string)}
? req.value(item as ItemCreateType)
: undefined ??
((item as ItemCreateType)[req.key] as string)}
</span>
</span>
</li>

View File

@@ -1,24 +1,32 @@
import { FilesService } from "@/entities/files";
import { ItemCreateType, ItemType } from "@/entities/item";
import Link from "next/link";
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { useDropzone } from "react-dropzone";
import { UseFormSetValue } from "react-hook-form";
import { useFormContext } 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 {
register,
setValue,
watch,
formState: { errors },
} = useFormContext<ItemCreateType>();
const watched_torrent_file = watch("torrent_file");
const watched_title = watch("title");
useEffect(() => {
register("torrent_file", { value: torrent_file });
}, [torrent_file, register]);
const onTorrentDrop = useCallback(
(acceptedFiles: File[]) => {
const file = acceptedFiles[0];
@@ -58,21 +66,23 @@ export const ItemTorrent = ({
>
<div className="flex flex-col items-center">
<Link
href={process.env.NEXT_PUBLIC_CONTENT_URL + "/" + torrent_file}
href={
process.env.NEXT_PUBLIC_CONTENT_URL + "/" + watched_torrent_file
}
className={clsx(
"p-4 bg-ac0 text-fg1 text-2xl rounded-lg",
!torrent_file && "bg-bg1 text-fg4"
!watched_torrent_file && "bg-bg1 text-fg4"
)}
>
Скачать {title}
Скачать {watched_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>
)}
<span className="w-full text-center text-err">
{errors.torrent_file?.message}
</span>
{isTorrentDragActive ? (
<span className="w-full text-center">
Изменить .torrent файл...

View File

@@ -1,32 +1,30 @@
import { ItemCreateType, ItemType } from "@/entities/item";
import { ItemCreateType } from "@/entities/item";
import { getYouTubeID } from "@/shared/utils/getYoutubeId";
import { UseFormRegister, UseFormSetValue } from "react-hook-form";
import { useFormContext } from "react-hook-form";
export const ItemTrailer = ({
trailer,
default_trailer,
editable,
setFormValue: setValue,
registerFormField: register,
}: {
trailer: string | undefined | null;
default_trailer: string | undefined | null;
editable: boolean;
setFormValue: UseFormSetValue<ItemType | ItemCreateType>;
registerFormField: UseFormRegister<ItemType | ItemCreateType>;
}) => {
const { register, setValue, watch } = useFormContext<ItemCreateType>();
const watched_trailer = watch("trailer");
return (
<>
{(trailer || editable) && (
{(watched_trailer || editable) && (
<div className="w-ful aspect-video">
{trailer && getYouTubeID(trailer) && (
{watched_trailer && getYouTubeID(watched_trailer) && (
<iframe
src={"https://youtube.com/embed/" + getYouTubeID(trailer)}
src={"https://youtube.com/embed/" + getYouTubeID(watched_trailer)}
className="w-full aspect-video rounded-lg mt-4"
allowFullScreen
/>
)}
{!trailer && editable && (
{!watched_trailer && editable && (
<div className="mt-4 w-full aspect-video border-dashed border-2 border-bg1 rounded-lg"></div>
)}
</div>
@@ -42,7 +40,7 @@ export const ItemTrailer = ({
setValue("trailer", e.target.value);
},
})}
defaultValue={default_trailer}
defaultValue={default_trailer ?? ""}
/>
</div>
)}