diff --git a/app/(pages)/(content)/anime/(anime-list)/layout.tsx b/app/(pages)/(content)/anime/(anime-list)/layout.tsx new file mode 100644 index 00000000..777161dd --- /dev/null +++ b/app/(pages)/(content)/anime/(anime-list)/layout.tsx @@ -0,0 +1,26 @@ +import { FC, ReactNode } from 'react'; + +import Block from '@/components/ui/block'; + +import NavBar from '@/features/anime/anime-list-navbar/anime-list-navbar.component'; +import Filters from '@/features/filters/anime-filters.component'; + +interface Props { + children: ReactNode; +} + +const AnimeListLayout: FC = async ({ children }) => { + return ( +
+ + + {children} + +
+ +
+
+ ); +}; + +export default AnimeListLayout; diff --git a/app/(pages)/(content)/anime/(anime-list)/page.tsx b/app/(pages)/(content)/anime/(anime-list)/page.tsx new file mode 100644 index 00000000..67f9e8b6 --- /dev/null +++ b/app/(pages)/(content)/anime/(anime-list)/page.tsx @@ -0,0 +1,68 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { redirect } from 'next/navigation'; +import { FC } from 'react'; + +import AnimeList from '@/features/anime/anime-list/anime-list.component'; + +import getQueryClient from '@/utils/get-query-client'; + +interface Props { + searchParams: { [key: string]: string | string[] | undefined }; +} + +const AnimeListPage: FC = async ({ searchParams }) => { + const page = searchParams.page; + + if (!page) { + return redirect( + `/anime?page=1&iPage=1&${new URLSearchParams(searchParams as Record).toString()}`, + ); + } + + /* const query = searchParams.search as string; + const media_type = searchParams.types; + const status = searchParams.statuses; + const season = searchParams.seasons; + const rating = searchParams.ratings; + const years = searchParams.years; + const genres = searchParams.genres; + const studios = searchParams.studios; + + const only_translated = searchParams.only_translated; + + const sort = searchParams.sort || 'score'; + const order = searchParams.order || 'desc'; + + const iPage = searchParams.iPage; + + const dataKeys = { + query, + media_type: typeof media_type === 'string' ? [media_type] : media_type, + status: typeof status === 'string' ? [status] : status, + season: typeof season === 'string' ? [season] : season, + rating: typeof rating === 'string' ? [rating] : rating, + years: typeof years === 'string' ? [years] : years, + genres: typeof genres === 'string' ? [genres] : genres, + studios: typeof studios === 'string' ? [studios] : studios, + only_translated: Boolean(only_translated), + sort: sort ? [`${sort}:${order}`] : undefined, + page: Number(page), + iPage: Number(iPage), + }; + + + + await prefetchAnimeCatalog(dataKeys); */ + + const queryClient = getQueryClient(); + + const dehydratedState = dehydrate(queryClient); + + return ( + + + + ); +}; + +export default AnimeListPage; diff --git a/app/(pages)/(content)/anime/(animeList)/layout.tsx b/app/(pages)/(content)/anime/(animeList)/layout.tsx deleted file mode 100644 index 69d7ca26..00000000 --- a/app/(pages)/(content)/anime/(animeList)/layout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { FC, ReactNode } from 'react'; - -import Block from '@/components/ui/block'; -import Card from '@/components/ui/card'; - -import NavBar from '@/features/anime/anime-list-navbar/anime-list-navbar.component'; -import Filters from '@/features/filters/anime-filters.component'; - -interface Props { - children: ReactNode; -} - -const AnimeListLayout: FC = async ({ children }) => { - return ( -
-
- - - {children} - - - - -
-
- ); -}; - -export default AnimeListLayout; diff --git a/app/(pages)/(content)/anime/(animeList)/page.tsx b/app/(pages)/(content)/anime/(animeList)/page.tsx deleted file mode 100644 index 4cbaeaa3..00000000 --- a/app/(pages)/(content)/anime/(animeList)/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { redirect } from 'next/navigation'; -import { FC } from 'react'; - -import AnimeList from '@/features/anime/anime-list/anime-list.component'; - -interface Props { - searchParams: Record; -} - -const AnimeListPage: FC = ({ searchParams }) => { - const page = searchParams.page; - - if (!page) { - return redirect( - `/anime?page=1&iPage=1&${new URLSearchParams( - searchParams, - ).toString()}`, - ); - } - - return ; -}; - -export default AnimeListPage; diff --git a/app/(pages)/(content)/anime/[slug]/(animeDetails)/characters/page.tsx b/app/(pages)/(content)/anime/[slug]/(anime-details)/characters/page.tsx similarity index 100% rename from app/(pages)/(content)/anime/[slug]/(animeDetails)/characters/page.tsx rename to app/(pages)/(content)/anime/[slug]/(anime-details)/characters/page.tsx diff --git a/app/(pages)/(content)/anime/[slug]/(animeDetails)/franchise/page.tsx b/app/(pages)/(content)/anime/[slug]/(anime-details)/franchise/page.tsx similarity index 100% rename from app/(pages)/(content)/anime/[slug]/(animeDetails)/franchise/page.tsx rename to app/(pages)/(content)/anime/[slug]/(anime-details)/franchise/page.tsx diff --git a/app/(pages)/(content)/anime/[slug]/(animeDetails)/media/page.tsx b/app/(pages)/(content)/anime/[slug]/(anime-details)/media/page.tsx similarity index 100% rename from app/(pages)/(content)/anime/[slug]/(animeDetails)/media/page.tsx rename to app/(pages)/(content)/anime/[slug]/(anime-details)/media/page.tsx diff --git a/app/(pages)/(content)/anime/[slug]/(animeDetails)/staff/page.tsx b/app/(pages)/(content)/anime/[slug]/(anime-details)/staff/page.tsx similarity index 100% rename from app/(pages)/(content)/anime/[slug]/(animeDetails)/staff/page.tsx rename to app/(pages)/(content)/anime/[slug]/(anime-details)/staff/page.tsx diff --git a/app/(pages)/(content)/anime/[slug]/(animeDetails)/comments/page.tsx b/app/(pages)/(content)/anime/[slug]/(animeDetails)/comments/page.tsx deleted file mode 100644 index dcb17f25..00000000 --- a/app/(pages)/(content)/anime/[slug]/(animeDetails)/comments/page.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; -import { Metadata, ResolvingMetadata } from 'next'; - -import Comments from '@/features/comments/comment-list/comment-list.component'; - -import getComments from '@/services/api/comments/getComments'; -import _generateMetadata from '@/utils/generate-metadata'; -import getQueryClient from '@/utils/get-query-client'; - -export async function generateMetadata( - { params }: { params: { slug: string } }, - parent: ResolvingMetadata, -): Promise { - const parentMetadata = await parent; - - return _generateMetadata({ - title: 'Коментарі', - description: parentMetadata.openGraph?.description, - images: parentMetadata.openGraph?.images, - }); -} - -interface Props { - params: { slug: string }; -} - -const AnimeCommentsPage = async ({ params: { slug } }: Props) => { - const queryClient = await getQueryClient(); - - await queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: ['comments', slug, 'anime'], - queryFn: ({ pageParam, meta }) => - getComments({ - params: { - slug, - content_type: 'anime', - }, - page: pageParam, - }), - }); - - return ( - - - - ); -}; - -export default AnimeCommentsPage; diff --git a/app/(pages)/(content)/anime/[slug]/anime.schema.ts b/app/(pages)/(content)/anime/[slug]/anime.schema.ts index 1d59732b..e4c9c3d6 100644 --- a/app/(pages)/(content)/anime/[slug]/anime.schema.ts +++ b/app/(pages)/(content)/anime/[slug]/anime.schema.ts @@ -22,7 +22,7 @@ const jsonSchema = ({ anime }: { anime: API.AnimeInfo }) => ({ '@id': 'https://hikka.io/anime/' + anime.slug, url: 'https://hikka.io/anime/' + anime.slug, name: anime.title_ua || anime.title_en || anime.title_ja, - image: anime.poster, + image: anime.image, }, }, ], @@ -31,7 +31,7 @@ const jsonSchema = ({ anime }: { anime: API.AnimeInfo }) => ({ '@type': 'TVSeries', name: anime.title_ua || anime.title_en || anime.title_ja, alternateName: anime.synonyms, - image: anime.poster, + image: anime.image, description: parseTextFromMarkDown( anime.synopsis_ua || anime.synopsis_en, ), diff --git a/app/(pages)/(content)/anime/[slug]/layout.queries.ts b/app/(pages)/(content)/anime/[slug]/layout.queries.ts index f628a1cf..8e2c930f 100644 --- a/app/(pages)/(content)/anime/[slug]/layout.queries.ts +++ b/app/(pages)/(content)/anime/[slug]/layout.queries.ts @@ -1,82 +1,27 @@ -import { QueryClient } from '@tanstack/query-core'; - -import getAnimeCharacters from '@/services/api/anime/getAnimeCharacters'; -import getAnimeFranchise from '@/services/api/anime/getAnimeFranchise'; -import getAnimeStaff from '@/services/api/anime/getAnimeStaff'; -import getFavourite from '@/services/api/favourite/getFavourite'; -import getFollowingWatchList from '@/services/api/watch/getFollowingWatchList'; -import getWatch from '@/services/api/watch/getWatch'; +import { prefetchCharacters } from '@/services/hooks/anime/use-characters'; +import { prefetchFranchise } from '@/services/hooks/anime/use-franchise'; +import { prefetchStaff } from '@/services/hooks/anime/use-staff'; +import { prefetchFavorite } from '@/services/hooks/favorite/use-favorite'; +import { prefetchFollowingWatchList } from '@/services/hooks/watch/use-following-watch-list'; +import { prefetchWatch } from '@/services/hooks/watch/use-watch'; import { getCookie } from '@/utils/cookies'; interface Props { - queryClient: QueryClient; params: { slug: string; }; } -const prefetchQueries = async ({ queryClient, params: { slug } }: Props) => { +const prefetchQueries = async ({ params: { slug } }: Props) => { const auth = await getCookie('auth'); await Promise.all([ - queryClient.prefetchInfiniteQuery({ - queryKey: ['characters', slug], - queryFn: ({ pageParam = 1 }) => - getAnimeCharacters({ - params: { slug }, - page: pageParam, - }), - initialPageParam: 1, - }), - queryClient.prefetchInfiniteQuery({ - queryKey: ['franchise', slug], - queryFn: ({ pageParam = 1, meta }) => - getAnimeFranchise({ - params: { slug }, - page: pageParam, - }), - initialPageParam: 1, - }), - queryClient.prefetchInfiniteQuery({ - queryKey: ['staff', slug], - queryFn: ({ pageParam = 1, meta }) => - getAnimeStaff({ - params: { slug }, - page: pageParam, - }), - initialPageParam: 1, - }), - auth - ? queryClient.prefetchQuery({ - queryKey: ['watch', slug], - queryFn: ({ meta }) => getWatch({ params: { slug } }), - }) - : undefined, - auth - ? queryClient.prefetchQuery({ - queryKey: ['favorite', slug, { content_type: 'anime' }], - queryFn: ({ meta }) => - getFavourite({ - params: { - slug: String(slug), - content_type: 'anime', - }, - }), - }) - : undefined, - auth - ? queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: ['followingWatchList', slug], - queryFn: ({ pageParam = 1, meta }) => - getFollowingWatchList({ - params: { - slug: slug, - }, - page: pageParam, - }), - }) - : undefined, + prefetchCharacters({ slug }), + prefetchFranchise({ slug }), + prefetchStaff({ slug }), + auth ? prefetchWatch({ slug }) : undefined, + auth ? prefetchFavorite({ slug, content_type: 'anime' }) : undefined, + auth ? prefetchFollowingWatchList({ slug }) : undefined, ]); }; diff --git a/app/(pages)/(content)/anime/[slug]/layout.tsx b/app/(pages)/(content)/anime/[slug]/layout.tsx index f3ba1fd7..5a57c638 100644 --- a/app/(pages)/(content)/anime/[slug]/layout.tsx +++ b/app/(pages)/(content)/anime/[slug]/layout.tsx @@ -14,7 +14,7 @@ import Actions from '@/features/anime/anime-view/actions/actions.component'; import Cover from '@/features/anime/anime-view/cover.component'; import Title from '@/features/anime/anime-view/title.component'; -import getAnimeInfo from '@/services/api/anime/getAnimeInfo'; +import { prefetchAnimeInfo } from '@/services/hooks/anime/use-anime-info'; import { ANIME_NAV_ROUTES, RELEASE_STATUS } from '@/utils/constants'; import getQueryClient from '@/utils/get-query-client'; @@ -36,10 +36,7 @@ export async function generateMetadata( const AnimeLayout: FC = async ({ params: { slug }, children }) => { const queryClient = await getQueryClient(); - await queryClient.prefetchQuery({ - queryKey: ['anime', slug], - queryFn: ({ meta }) => getAnimeInfo({ params: { slug } }), - }); + await prefetchAnimeInfo({ slug }); const anime: API.AnimeInfo | undefined = queryClient.getQueryData([ 'anime', @@ -50,7 +47,7 @@ const AnimeLayout: FC = async ({ params: { slug }, children }) => { return redirect('/'); } - await prefetchQueries({ queryClient, params: { slug } }); + await prefetchQueries({ params: { slug } }); const dehydratedState = dehydrate(queryClient); diff --git a/app/(pages)/(content)/characters/[slug]/(characterDetails)/anime/page.tsx b/app/(pages)/(content)/characters/[slug]/(character-details)/anime/page.tsx similarity index 100% rename from app/(pages)/(content)/characters/[slug]/(characterDetails)/anime/page.tsx rename to app/(pages)/(content)/characters/[slug]/(character-details)/anime/page.tsx diff --git a/app/(pages)/(content)/characters/[slug]/(characterDetails)/voices/page.tsx b/app/(pages)/(content)/characters/[slug]/(character-details)/voices/page.tsx similarity index 100% rename from app/(pages)/(content)/characters/[slug]/(characterDetails)/voices/page.tsx rename to app/(pages)/(content)/characters/[slug]/(character-details)/voices/page.tsx diff --git a/app/(pages)/(content)/characters/[slug]/layout.queries.ts b/app/(pages)/(content)/characters/[slug]/layout.queries.ts index fa0ecfa5..d6eaeaf8 100644 --- a/app/(pages)/(content)/characters/[slug]/layout.queries.ts +++ b/app/(pages)/(content)/characters/[slug]/layout.queries.ts @@ -1,50 +1,18 @@ -import { QueryClient } from '@tanstack/query-core'; - -import getCharacterAnime from '@/services/api/characters/getCharacterAnime'; -import getCharacterVoices from '@/services/api/characters/getCharacterVoices'; -import getFavourite from '@/services/api/favourite/getFavourite'; +import { prefetchCharacterAnime } from '@/services/hooks/characters/use-character-anime'; +import { prefetchCharacterVoices } from '@/services/hooks/characters/use-character-voices'; +import { prefetchFavorite } from '@/services/hooks/favorite/use-favorite'; interface Props { - queryClient: QueryClient; params: { slug: string; }; } -const prefetchQueries = async ({ queryClient, params: { slug } }: Props) => { +const prefetchQueries = async ({ params: { slug } }: Props) => { await Promise.all([ - await queryClient.prefetchInfiniteQuery({ - queryKey: ['characterAnime', slug], - queryFn: ({ meta }) => - getCharacterAnime({ - params: { - slug, - }, - }), - initialPageParam: 1, - }), - - await queryClient.prefetchInfiniteQuery({ - queryKey: ['characterVoices', slug], - queryFn: ({ meta }) => - getCharacterVoices({ - params: { - slug, - }, - }), - initialPageParam: 1, - }), - - await queryClient.prefetchQuery({ - queryKey: ['favorite', slug, { content_type: 'character' }], - queryFn: ({ meta }) => - getFavourite({ - params: { - slug: String(slug), - content_type: 'character', - }, - }), - }), + prefetchCharacterAnime({ slug }), + prefetchCharacterVoices({ slug }), + prefetchFavorite({ slug, content_type: 'character' }), ]); }; diff --git a/app/(pages)/(content)/characters/[slug]/layout.tsx b/app/(pages)/(content)/characters/[slug]/layout.tsx index b1a8a28f..6224d977 100644 --- a/app/(pages)/(content)/characters/[slug]/layout.tsx +++ b/app/(pages)/(content)/characters/[slug]/layout.tsx @@ -13,7 +13,7 @@ import SubBar from '@/components/navigation/sub-nav'; import Cover from '@/features/characters/character-view/cover.component'; import Title from '@/features/characters/character-view/title.component'; -import getCharacterInfo from '@/services/api/characters/getCharacterInfo'; +import { prefetchCharacterInfo } from '@/services/hooks/characters/use-character-info'; import { CHARACTER_NAV_ROUTES } from '@/utils/constants'; import getQueryClient from '@/utils/get-query-client'; @@ -36,21 +36,18 @@ export async function generateMetadata( const CharacterLayout: FC = async ({ params: { slug }, children }) => { const queryClient = await getQueryClient(); - const character = await queryClient.fetchQuery({ - queryKey: ['character', slug], - queryFn: ({ meta }) => - getCharacterInfo({ - params: { - slug, - }, - }), - }); + await prefetchCharacterInfo({ slug }); + + const character: API.Character | undefined = queryClient.getQueryData([ + 'character', + slug, + ]); if (!character) { return redirect('/'); } - await prefetchQueries({ queryClient, params: { slug } }); + await prefetchQueries({ params: { slug } }); const dehydratedState = dehydrate(queryClient); diff --git a/app/(pages)/(content)/characters/[slug]/page.tsx b/app/(pages)/(content)/characters/[slug]/page.tsx index 26f31028..cf90066d 100644 --- a/app/(pages)/(content)/characters/[slug]/page.tsx +++ b/app/(pages)/(content)/characters/[slug]/page.tsx @@ -1,5 +1,7 @@ +import Manga from '@/features/characters/character-view//manga.component'; import Anime from '@/features/characters/character-view/anime.component'; import Description from '@/features/characters/character-view/description.component'; +import Novel from '@/features/characters/character-view/novel.component'; import Voices from '@/features/characters/character-view/voices.component'; const CharacterPage = () => { @@ -7,6 +9,8 @@ const CharacterPage = () => {
+ +
); diff --git a/app/(pages)/(content)/manga/(manga-list)/layout.tsx b/app/(pages)/(content)/manga/(manga-list)/layout.tsx new file mode 100644 index 00000000..c8d2d7d7 --- /dev/null +++ b/app/(pages)/(content)/manga/(manga-list)/layout.tsx @@ -0,0 +1,26 @@ +import { FC, ReactNode } from 'react'; + +import Block from '@/components/ui/block'; + +import ReadFilters from '@/features/filters/read-filters.component'; +import NavBar from '@/features/manga/manga-list-navbar/manga-list-navbar.component'; + +interface Props { + children: ReactNode; +} + +const MangaListLayout: FC = async ({ children }) => { + return ( +
+ + + {children} + +
+ +
+
+ ); +}; + +export default MangaListLayout; diff --git a/app/(pages)/(content)/manga/(manga-list)/page.tsx b/app/(pages)/(content)/manga/(manga-list)/page.tsx new file mode 100644 index 00000000..3911f9ad --- /dev/null +++ b/app/(pages)/(content)/manga/(manga-list)/page.tsx @@ -0,0 +1,60 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { redirect } from 'next/navigation'; +import { FC } from 'react'; + +import MangaList from '@/features/manga/manga-list/manga-list.component'; + +import getQueryClient from '@/utils/get-query-client'; + +interface Props { + searchParams: Record; +} + +const MangaListPage: FC = async ({ searchParams }) => { + const page = searchParams.page; + + if (!page) { + return redirect( + `/manga?page=1&iPage=1&${new URLSearchParams(searchParams as Record).toString()}`, + ); + } + + /* const query = searchParams.search as string; + const media_type = searchParams.types; + const status = searchParams.statuses; + const years = searchParams.years; + const genres = searchParams.genres; + + const only_translated = searchParams.only_translated; + + const sort = searchParams.sort || 'score'; + const order = searchParams.order || 'desc'; + + const iPage = searchParams.iPage; + + const dataKeys = { + query, + media_type: typeof media_type === 'string' ? [media_type] : media_type, + status: typeof status === 'string' ? [status] : status, + years: typeof years === 'string' ? [years] : years, + genres: typeof genres === 'string' ? [genres] : genres, + only_translated: Boolean(only_translated), + sort: sort ? [`${sort}:${order}`] : undefined, + page: Number(page), + iPage: Number(iPage), + }; */ + + const queryClient = getQueryClient(); + + // await prefetchMangaCatalog(dataKeys); + + const dehydratedState = dehydrate(queryClient); + + return ( + + + + ); +}; + +export default MangaListPage; diff --git a/app/(pages)/(content)/manga/[slug]/(manga-details)/characters/page.tsx b/app/(pages)/(content)/manga/[slug]/(manga-details)/characters/page.tsx new file mode 100644 index 00000000..5581b2c9 --- /dev/null +++ b/app/(pages)/(content)/manga/[slug]/(manga-details)/characters/page.tsx @@ -0,0 +1,28 @@ +import { Metadata, ResolvingMetadata } from 'next'; + +import Characters from '@/features/manga/manga-view/characters/characters.component'; + +import _generateMetadata from '@/utils/generate-metadata'; + +export async function generateMetadata( + { params }: { params: { slug: string } }, + parent: ResolvingMetadata, +): Promise { + const parentMetadata = await parent; + + return _generateMetadata({ + title: 'Персонажі', + description: parentMetadata.openGraph?.description, + images: parentMetadata.openGraph?.images, + }); +} + +const MangaCharactersPage = async () => { + return ( +
+ +
+ ); +}; + +export default MangaCharactersPage; diff --git a/app/(pages)/(content)/manga/[slug]/(manga-details)/franchise/page.tsx b/app/(pages)/(content)/manga/[slug]/(manga-details)/franchise/page.tsx new file mode 100644 index 00000000..1fddcd24 --- /dev/null +++ b/app/(pages)/(content)/manga/[slug]/(manga-details)/franchise/page.tsx @@ -0,0 +1,28 @@ +import { Metadata, ResolvingMetadata } from 'next'; + +import Franchise from '@/features/manga/manga-view/franchise.component'; + +import _generateMetadata from '@/utils/generate-metadata'; + +export async function generateMetadata( + { params }: { params: { slug: string } }, + parent: ResolvingMetadata, +): Promise { + const parentMetadata = await parent; + + return _generateMetadata({ + title: 'Пов’язане', + description: parentMetadata.openGraph?.description, + images: parentMetadata.openGraph?.images, + }); +} + +const MangaFranchisePage = async () => { + return ( +
+ +
+ ); +}; + +export default MangaFranchisePage; diff --git a/app/(pages)/(content)/manga/[slug]/(manga-details)/staff/page.tsx b/app/(pages)/(content)/manga/[slug]/(manga-details)/staff/page.tsx new file mode 100644 index 00000000..eb1eec32 --- /dev/null +++ b/app/(pages)/(content)/manga/[slug]/(manga-details)/staff/page.tsx @@ -0,0 +1,28 @@ +import { Metadata, ResolvingMetadata } from 'next'; + +import Staff from '@/features/manga/manga-view/staff.component'; + +import _generateMetadata from '@/utils/generate-metadata'; + +export async function generateMetadata( + { params }: { params: { slug: string } }, + parent: ResolvingMetadata, +): Promise { + const parentMetadata = await parent; + + return _generateMetadata({ + title: 'Автори', + description: parentMetadata.openGraph?.description, + images: parentMetadata.openGraph?.images, + }); +} + +const MangaStaffPage = async () => { + return ( +
+ +
+ ); +}; + +export default MangaStaffPage; diff --git a/app/(pages)/(content)/manga/[slug]/layout.metadata.ts b/app/(pages)/(content)/manga/[slug]/layout.metadata.ts new file mode 100644 index 00000000..34176f03 --- /dev/null +++ b/app/(pages)/(content)/manga/[slug]/layout.metadata.ts @@ -0,0 +1,45 @@ +import { Metadata } from 'next'; + +import getMangaInfo, { + Response as MangaResponse, +} from '@/services/api/manga/getMangaInfo'; +import _generateMetadata from '@/utils/generate-metadata'; +import parseTextFromMarkDown from '@/utils/parse-text-from-markdown'; +import truncateText from '@/utils/truncate-text'; + +export interface MetadataProps { + params: { slug: string }; + searchParams: { [key: string]: string | string[] | undefined }; +} + +export default async function generateMetadata({ + params, +}: MetadataProps): Promise { + const slug = params.slug; + + const manga: MangaResponse = await getMangaInfo({ params: { slug } }); + + const startDate = manga.start_date + ? new Date(manga.start_date * 1000).getFullYear() + : null; + const title = + (manga.title_ua || manga.title_en || manga.title_original) + + (startDate ? ` (${startDate})` : ''); + let synopsis: string | null = truncateText( + parseTextFromMarkDown(manga.synopsis_ua || manga.synopsis_en), + 150, + true, + ); + + return _generateMetadata({ + title: { + default: title, + template: title + ' / %s / Hikka', + }, + description: synopsis, + images: `https://preview.hikka.io/manga/${slug}/${manga.updated}`, + other: { + 'mal-id': manga.mal_id, + }, + }); +} diff --git a/app/(pages)/(content)/manga/[slug]/layout.queries.ts b/app/(pages)/(content)/manga/[slug]/layout.queries.ts new file mode 100644 index 00000000..c49b876e --- /dev/null +++ b/app/(pages)/(content)/manga/[slug]/layout.queries.ts @@ -0,0 +1,20 @@ +import { prefetchFavorite } from '@/services/hooks/favorite/use-favorite'; +import { prefetchMangaCharacters } from '@/services/hooks/manga/use-manga-characters'; +import { getCookie } from '@/utils/cookies'; + +interface Props { + params: { + slug: string; + }; +} + +const prefetchQueries = async ({ params: { slug } }: Props) => { + const auth = await getCookie('auth'); + + await Promise.all([ + prefetchMangaCharacters({ slug }), + auth ? prefetchFavorite({ slug, content_type: 'manga' }) : undefined, + ]); +}; + +export default prefetchQueries; diff --git a/app/(pages)/(content)/manga/[slug]/layout.tsx b/app/(pages)/(content)/manga/[slug]/layout.tsx new file mode 100644 index 00000000..14bb435e --- /dev/null +++ b/app/(pages)/(content)/manga/[slug]/layout.tsx @@ -0,0 +1,101 @@ +import { dehydrate } from '@tanstack/query-core'; +import { HydrationBoundary } from '@tanstack/react-query'; +import { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { FC, PropsWithChildren } from 'react'; + +import Breadcrumbs from '@/components/navigation/nav-breadcrumbs'; +import NavMenu from '@/components/navigation/nav-dropdown'; +import InternalNavBar from '@/components/navigation/nav-tabs'; +import SubBar from '@/components/navigation/sub-nav'; + +import Actions from '@/features/manga/manga-view/actions/actions.component'; +import Cover from '@/features/manga/manga-view/cover.component'; +import Title from '@/features/manga/manga-view/title.component'; + +import { prefetchMangaInfo } from '@/services/hooks/manga/use-manga-info'; +import { MANGA_NAV_ROUTES, RELEASE_STATUS } from '@/utils/constants'; +import getQueryClient from '@/utils/get-query-client'; + +import _generateMetadata, { MetadataProps } from './layout.metadata'; +import prefetchQueries from './layout.queries'; + +interface Props extends PropsWithChildren { + params: { + slug: string; + }; +} + +export async function generateMetadata( + props: MetadataProps, +): Promise { + return await _generateMetadata(props); +} + +const MangaLayout: FC = async ({ params: { slug }, children }) => { + const queryClient = getQueryClient(); + + await prefetchMangaInfo({ slug }); + + const manga: API.MangaInfo | undefined = queryClient.getQueryData([ + 'manga', + slug, + ]); + + if (!manga) { + return redirect('/'); + } + + await prefetchQueries({ params: { slug } }); + + const dehydratedState = dehydrate(queryClient); + + return ( + + +
+
+ + {manga?.title_ua || + manga?.title_en || + manga?.title_original} + +
+ + + + + +
+
+ +
+ +
+
+
+ + {children} + </div> + </div> + </HydrationBoundary> + ); +}; + +export default MangaLayout; diff --git a/app/(pages)/(content)/manga/[slug]/page.tsx b/app/(pages)/(content)/manga/[slug]/page.tsx new file mode 100644 index 00000000..5089ab22 --- /dev/null +++ b/app/(pages)/(content)/manga/[slug]/page.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react'; + +import Characters from '@/features/manga/manga-view/characters/characters.component'; +import Description from '@/features/manga/manga-view/description.component'; +import Details from '@/features/manga/manga-view/details/details.component'; +import Franchise from '@/features/manga/manga-view/franchise.component'; +import Links from '@/features/manga/manga-view/links/links.component'; +import ReadStats from '@/features/manga/manga-view/read-stats/read-stats.component'; +import Staff from '@/features/manga/manga-view/staff.component'; + +interface Props { + params: { + slug: string; + }; +} + +const MangaPage: FC<Props> = async ({ params }) => { + return ( + <div className="grid grid-cols-1 gap-12 lg:grid-cols-[1fr_33%] lg:gap-16 xl:grid-cols-[1fr_30%]"> + <div className="relative order-2 flex flex-col gap-12 lg:order-1"> + <Description /> + <Characters /> + <Franchise /> + <Staff /> + <div className="flex flex-col gap-12 lg:hidden"> + <ReadStats /> + <Links /> + </div> + </div> + <div className="order-1 flex flex-col gap-12 lg:order-2"> + <Details /> + <div className="hidden lg:flex lg:flex-col lg:gap-12"> + <ReadStats /> + <Links /> + </div> + </div> + </div> + ); +}; + +export default MangaPage; diff --git a/app/(pages)/(content)/novel/(novel-list)/layout.tsx b/app/(pages)/(content)/novel/(novel-list)/layout.tsx new file mode 100644 index 00000000..a2f419f1 --- /dev/null +++ b/app/(pages)/(content)/novel/(novel-list)/layout.tsx @@ -0,0 +1,26 @@ +import { FC, ReactNode } from 'react'; + +import Block from '@/components/ui/block'; + +import ReadFilters from '@/features/filters/read-filters.component'; +import NavBar from '@/features/novel/novel-list-navbar/novel-list-navbar.component'; + +interface Props { + children: ReactNode; +} + +const NovelListLayout: FC<Props> = async ({ children }) => { + return ( + <div className="grid grid-cols-1 justify-center lg:grid-cols-[1fr_25%] lg:items-start lg:justify-between lg:gap-16"> + <Block> + <NavBar /> + {children} + </Block> + <div className="sticky top-20 order-1 hidden w-full opacity-60 transition-opacity hover:opacity-100 lg:order-2 lg:block"> + <ReadFilters content_type="novel" sort_type="novel" /> + </div> + </div> + ); +}; + +export default NovelListLayout; diff --git a/app/(pages)/(content)/novel/(novel-list)/page.tsx b/app/(pages)/(content)/novel/(novel-list)/page.tsx new file mode 100644 index 00000000..fc73feb2 --- /dev/null +++ b/app/(pages)/(content)/novel/(novel-list)/page.tsx @@ -0,0 +1,60 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { redirect } from 'next/navigation'; +import { FC } from 'react'; + +import NovelList from '@/features/novel/novel-list/novel-list.component'; + +import getQueryClient from '@/utils/get-query-client'; + +interface Props { + searchParams: Record<string, string>; +} + +const NovelListPage: FC<Props> = async ({ searchParams }) => { + const page = searchParams.page; + + if (!page) { + return redirect( + `/novel?page=1&iPage=1&${new URLSearchParams(searchParams as Record<string, string>).toString()}`, + ); + } + + /* const query = searchParams.search as string; + const media_type = searchParams.types; + const status = searchParams.statuses; + const years = searchParams.years; + const genres = searchParams.genres; + + const only_translated = searchParams.only_translated; + + const sort = searchParams.sort || 'score'; + const order = searchParams.order || 'desc'; + + const iPage = searchParams.iPage; + + const dataKeys = { + query, + media_type: typeof media_type === 'string' ? [media_type] : media_type, + status: typeof status === 'string' ? [status] : status, + years: typeof years === 'string' ? [years] : years, + genres: typeof genres === 'string' ? [genres] : genres, + only_translated: Boolean(only_translated), + sort: sort ? [`${sort}:${order}`] : undefined, + page: Number(page), + iPage: Number(iPage), + }; + */ + const queryClient = getQueryClient(); + + // await prefetchNovelCatalog(dataKeys); + + const dehydratedState = dehydrate(queryClient); + + return ( + <HydrationBoundary state={dehydratedState}> + <NovelList searchParams={searchParams} /> + </HydrationBoundary> + ); +}; + +export default NovelListPage; diff --git a/app/(pages)/(content)/novel/[slug]/(novel-details)/characters/page.tsx b/app/(pages)/(content)/novel/[slug]/(novel-details)/characters/page.tsx new file mode 100644 index 00000000..1a262d9f --- /dev/null +++ b/app/(pages)/(content)/novel/[slug]/(novel-details)/characters/page.tsx @@ -0,0 +1,28 @@ +import { Metadata, ResolvingMetadata } from 'next'; + +import Characters from '@/features/novel/novel-view/characters/characters.component'; + +import _generateMetadata from '@/utils/generate-metadata'; + +export async function generateMetadata( + { params }: { params: { slug: string } }, + parent: ResolvingMetadata, +): Promise<Metadata> { + const parentMetadata = await parent; + + return _generateMetadata({ + title: 'Персонажі', + description: parentMetadata.openGraph?.description, + images: parentMetadata.openGraph?.images, + }); +} + +const AnimeCharactersPage = async () => { + return ( + <div className="flex flex-col gap-12"> + <Characters extended /> + </div> + ); +}; + +export default AnimeCharactersPage; diff --git a/app/(pages)/(content)/novel/[slug]/(novel-details)/franchise/page.tsx b/app/(pages)/(content)/novel/[slug]/(novel-details)/franchise/page.tsx new file mode 100644 index 00000000..aec97f84 --- /dev/null +++ b/app/(pages)/(content)/novel/[slug]/(novel-details)/franchise/page.tsx @@ -0,0 +1,28 @@ +import { Metadata, ResolvingMetadata } from 'next'; + +import Franchise from '@/features/novel/novel-view/franchise.component'; + +import _generateMetadata from '@/utils/generate-metadata'; + +export async function generateMetadata( + { params }: { params: { slug: string } }, + parent: ResolvingMetadata, +): Promise<Metadata> { + const parentMetadata = await parent; + + return _generateMetadata({ + title: 'Пов’язане', + description: parentMetadata.openGraph?.description, + images: parentMetadata.openGraph?.images, + }); +} + +const NovelFranchisePage = async () => { + return ( + <div className="flex flex-col gap-12"> + <Franchise extended /> + </div> + ); +}; + +export default NovelFranchisePage; diff --git a/app/(pages)/(content)/novel/[slug]/(novel-details)/staff/page.tsx b/app/(pages)/(content)/novel/[slug]/(novel-details)/staff/page.tsx new file mode 100644 index 00000000..cf308232 --- /dev/null +++ b/app/(pages)/(content)/novel/[slug]/(novel-details)/staff/page.tsx @@ -0,0 +1,28 @@ +import { Metadata, ResolvingMetadata } from 'next'; + +import Staff from '@/features/novel/novel-view/staff.component'; + +import _generateMetadata from '@/utils/generate-metadata'; + +export async function generateMetadata( + { params }: { params: { slug: string } }, + parent: ResolvingMetadata, +): Promise<Metadata> { + const parentMetadata = await parent; + + return _generateMetadata({ + title: 'Автори', + description: parentMetadata.openGraph?.description, + images: parentMetadata.openGraph?.images, + }); +} + +const NovelStaffPage = async () => { + return ( + <div className="flex flex-col gap-12"> + <Staff extended /> + </div> + ); +}; + +export default NovelStaffPage; diff --git a/app/(pages)/(content)/novel/[slug]/layout.metadata.ts b/app/(pages)/(content)/novel/[slug]/layout.metadata.ts new file mode 100644 index 00000000..68fc884b --- /dev/null +++ b/app/(pages)/(content)/novel/[slug]/layout.metadata.ts @@ -0,0 +1,45 @@ +import { Metadata } from 'next'; + +import getNovelInfo, { + Response as NovelResponse, +} from '@/services/api/novel/getNovelInfo'; +import _generateMetadata from '@/utils/generate-metadata'; +import parseTextFromMarkDown from '@/utils/parse-text-from-markdown'; +import truncateText from '@/utils/truncate-text'; + +export interface MetadataProps { + params: { slug: string }; + searchParams: { [key: string]: string | string[] | undefined }; +} + +export default async function generateMetadata({ + params, +}: MetadataProps): Promise<Metadata> { + const slug = params.slug; + + const novel: NovelResponse = await getNovelInfo({ params: { slug } }); + + const startDate = novel.start_date + ? new Date(novel.start_date * 1000).getFullYear() + : null; + const title = + (novel.title_ua || novel.title_en || novel.title_original) + + (startDate ? ` (${startDate})` : ''); + let synopsis: string | null = truncateText( + parseTextFromMarkDown(novel.synopsis_ua || novel.synopsis_en), + 150, + true, + ); + + return _generateMetadata({ + title: { + default: title, + template: title + ' / %s / Hikka', + }, + description: synopsis, + images: `https://preview.hikka.io/novel/${slug}/${novel.updated}`, + other: { + 'mal-id': novel.mal_id, + }, + }); +} diff --git a/app/(pages)/(content)/novel/[slug]/layout.queries.ts b/app/(pages)/(content)/novel/[slug]/layout.queries.ts new file mode 100644 index 00000000..45c2c0a0 --- /dev/null +++ b/app/(pages)/(content)/novel/[slug]/layout.queries.ts @@ -0,0 +1,20 @@ +import { prefetchFavorite } from '@/services/hooks/favorite/use-favorite'; +import { prefetchNovelCharacters } from '@/services/hooks/novel/use-novel-characters'; +import { getCookie } from '@/utils/cookies'; + +interface Props { + params: { + slug: string; + }; +} + +const prefetchQueries = async ({ params: { slug } }: Props) => { + const auth = await getCookie('auth'); + + await Promise.all([ + prefetchNovelCharacters({ slug }), + auth ? prefetchFavorite({ slug, content_type: 'novel' }) : undefined, + ]); +}; + +export default prefetchQueries; diff --git a/app/(pages)/(content)/novel/[slug]/layout.tsx b/app/(pages)/(content)/novel/[slug]/layout.tsx new file mode 100644 index 00000000..1d2a8b72 --- /dev/null +++ b/app/(pages)/(content)/novel/[slug]/layout.tsx @@ -0,0 +1,101 @@ +import { dehydrate } from '@tanstack/query-core'; +import { HydrationBoundary } from '@tanstack/react-query'; +import { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { FC, PropsWithChildren } from 'react'; + +import Breadcrumbs from '@/components/navigation/nav-breadcrumbs'; +import NavMenu from '@/components/navigation/nav-dropdown'; +import InternalNavBar from '@/components/navigation/nav-tabs'; +import SubBar from '@/components/navigation/sub-nav'; + +import Actions from '@/features/novel/novel-view/actions/actions.component'; +import Cover from '@/features/novel/novel-view/cover.component'; +import Title from '@/features/novel/novel-view/title.component'; + +import { prefetchNovelInfo } from '@/services/hooks/novel/use-novel-info'; +import { NOVEL_NAV_ROUTES, RELEASE_STATUS } from '@/utils/constants'; +import getQueryClient from '@/utils/get-query-client'; + +import _generateMetadata, { MetadataProps } from './layout.metadata'; +import prefetchQueries from './layout.queries'; + +interface Props extends PropsWithChildren { + params: { + slug: string; + }; +} + +export async function generateMetadata( + props: MetadataProps, +): Promise<Metadata> { + return await _generateMetadata(props); +} + +const NovelLayout: FC<Props> = async ({ params: { slug }, children }) => { + const queryClient = getQueryClient(); + + await prefetchNovelInfo({ slug }); + + const novel: API.NovelInfo | undefined = queryClient.getQueryData([ + 'novel', + slug, + ]); + + if (!novel) { + return redirect('/'); + } + + await prefetchQueries({ params: { slug } }); + + const dehydratedState = dehydrate(queryClient); + + return ( + <HydrationBoundary state={dehydratedState}> + <Breadcrumbs> + <div className="flex w-auto items-center gap-4 overflow-hidden whitespace-nowrap"> + <div + className="size-2 rounded-full bg-white" + style={{ + backgroundColor: + RELEASE_STATUS[novel?.status].color, + }} + /> + <Link + href={'/novel/' + novel?.slug} + className="flex-1 overflow-hidden text-ellipsis text-sm font-bold hover:underline" + > + {novel?.title_ua || + novel?.title_en || + novel?.title_original} + </Link> + </div> + <NavMenu + routes={NOVEL_NAV_ROUTES} + urlPrefix={`/novel/${slug}`} + /> + </Breadcrumbs> + <SubBar> + <InternalNavBar + routes={NOVEL_NAV_ROUTES} + urlPrefix={`/novel/${slug}`} + /> + </SubBar> + <div className="grid grid-cols-1 gap-12 lg:grid-cols-[20%_1fr] lg:gap-16"> + <div className="flex flex-col gap-4"> + <Cover /> + <div className="flex w-full flex-col gap-4 lg:sticky lg:top-20 lg:self-start"> + <Actions /> + </div> + </div> + <div className="flex flex-col gap-12"> + <Title /> + {children} + </div> + </div> + </HydrationBoundary> + ); +}; + +export default NovelLayout; diff --git a/app/(pages)/(content)/novel/[slug]/page.tsx b/app/(pages)/(content)/novel/[slug]/page.tsx new file mode 100644 index 00000000..26da30a0 --- /dev/null +++ b/app/(pages)/(content)/novel/[slug]/page.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react'; + +import Characters from '@/features/novel/novel-view/characters/characters.component'; +import Description from '@/features/novel/novel-view/description.component'; +import Details from '@/features/novel/novel-view/details/details.component'; +import Franchise from '@/features/novel/novel-view/franchise.component'; +import Links from '@/features/novel/novel-view/links/links.component'; +import ReadStats from '@/features/novel/novel-view/read-stats/read-stats.component'; +import Staff from '@/features/novel/novel-view/staff.component'; + +interface Props { + params: { + slug: string; + }; +} + +const NovelPage: FC<Props> = async ({ params }) => { + return ( + <div className="grid grid-cols-1 gap-12 lg:grid-cols-[1fr_33%] lg:gap-16 xl:grid-cols-[1fr_30%]"> + <div className="relative order-2 flex flex-col gap-12 lg:order-1"> + <Description /> + <Characters /> + <Franchise /> + <Staff /> + <div className="flex flex-col gap-12 lg:hidden"> + <ReadStats /> + <Links /> + </div> + </div> + <div className="order-1 flex flex-col gap-12 lg:order-2"> + <Details /> + <div className="hidden lg:flex lg:flex-col lg:gap-12"> + <ReadStats /> + <Links /> + </div> + </div> + </div> + ); +}; + +export default NovelPage; diff --git a/app/(pages)/(content)/people/[slug]/(personDetails)/anime/page.tsx b/app/(pages)/(content)/people/[slug]/(person-details)/anime/page.tsx similarity index 100% rename from app/(pages)/(content)/people/[slug]/(personDetails)/anime/page.tsx rename to app/(pages)/(content)/people/[slug]/(person-details)/anime/page.tsx diff --git a/app/(pages)/(content)/people/[slug]/(personDetails)/characters/page.tsx b/app/(pages)/(content)/people/[slug]/(person-details)/characters/page.tsx similarity index 100% rename from app/(pages)/(content)/people/[slug]/(personDetails)/characters/page.tsx rename to app/(pages)/(content)/people/[slug]/(person-details)/characters/page.tsx diff --git a/app/(pages)/(content)/people/[slug]/layout.queries.ts b/app/(pages)/(content)/people/[slug]/layout.queries.ts index 745df95b..f6fd017b 100644 --- a/app/(pages)/(content)/people/[slug]/layout.queries.ts +++ b/app/(pages)/(content)/people/[slug]/layout.queries.ts @@ -1,38 +1,16 @@ -import { QueryClient } from '@tanstack/query-core'; - -import getPersonAnime from '@/services/api/people/getPersonAnime'; -import getPersonCharacters from '@/services/api/people/getPersonCharacters'; +import { prefetchPersonAnime } from '@/services/hooks/people/use-person-anime'; +import { prefetchPersonCharacters } from '@/services/hooks/people/use-person-characters'; interface Props { - queryClient: QueryClient; params: { slug: string; }; } -const prefetchQueries = async ({ queryClient, params: { slug } }: Props) => { +const prefetchQueries = async ({ params: { slug } }: Props) => { await Promise.all([ - await queryClient.prefetchInfiniteQuery({ - queryKey: ['personAnime', slug], - queryFn: ({ meta }) => - getPersonAnime({ - params: { - slug, - }, - }), - initialPageParam: 1, - }), - - await queryClient.prefetchInfiniteQuery({ - queryKey: ['personCharacters', slug], - queryFn: ({ meta }) => - getPersonCharacters({ - params: { - slug, - }, - }), - initialPageParam: 1, - }), + prefetchPersonAnime({ slug }), + prefetchPersonCharacters({ slug }), ]); }; diff --git a/app/(pages)/(content)/people/[slug]/layout.tsx b/app/(pages)/(content)/people/[slug]/layout.tsx index 7d8a70b4..b5c0409b 100644 --- a/app/(pages)/(content)/people/[slug]/layout.tsx +++ b/app/(pages)/(content)/people/[slug]/layout.tsx @@ -13,7 +13,7 @@ import SubBar from '@/components/navigation/sub-nav'; import Cover from '@/features/people/person-view/cover.component'; import Title from '@/features/people/person-view/title.component'; -import getPersonInfo from '@/services/api/people/getPersonInfo'; +import { prefetchPersonInfo } from '@/services/hooks/people/use-person-info'; import { PERSON_NAV_ROUTES } from '@/utils/constants'; import getQueryClient from '@/utils/get-query-client'; @@ -34,23 +34,20 @@ export async function generateMetadata( } const PersonLayout: FC<Props> = async ({ params: { slug }, children }) => { - const queryClient = await getQueryClient(); + const queryClient = getQueryClient(); - const person = await queryClient.fetchQuery({ - queryKey: ['person', slug], - queryFn: ({ meta }) => - getPersonInfo({ - params: { - slug, - }, - }), - }); + await prefetchPersonInfo({ slug }); + + const person: API.Person | undefined = queryClient.getQueryData([ + 'person', + slug, + ]); if (!person) { return redirect('/'); } - await prefetchQueries({ queryClient, params: { slug } }); + await prefetchQueries({ params: { slug } }); const dehydratedState = dehydrate(queryClient); diff --git a/app/(pages)/(content)/people/[slug]/page.tsx b/app/(pages)/(content)/people/[slug]/page.tsx index 025698fe..e6aea98f 100644 --- a/app/(pages)/(content)/people/[slug]/page.tsx +++ b/app/(pages)/(content)/people/[slug]/page.tsx @@ -1,11 +1,15 @@ import Anime from '@/features/people/person-view/anime.component'; import Characters from '@/features/people/person-view/characters.component'; +import Manga from '@/features/people/person-view/manga.component'; +import Novel from '@/features/people/person-view/novel.component'; const PersonPage = () => { return ( <div className="relative flex flex-col gap-12 "> <Characters /> <Anime /> + <Manga /> + <Novel /> </div> ); }; diff --git a/app/(pages)/(home)/page.queries.ts b/app/(pages)/(home)/page.queries.ts index fdf4ce57..0c3a14ef 100644 --- a/app/(pages)/(home)/page.queries.ts +++ b/app/(pages)/(home)/page.queries.ts @@ -1,113 +1,55 @@ -import { QueryClient } from '@tanstack/query-core'; - -import getCollections from '@/services/api/collections/getCollections'; -import getLatestComments from '@/services/api/comments/getLatestComments'; -import getFollowingHistory from '@/services/api/history/getFollowingHistory'; -import getAnimeSchedule from '@/services/api/stats/getAnimeSchedule'; -import getWatchList from '@/services/api/watch/getWatchList'; +import { prefetchAnimeCatalog } from '@/services/hooks/anime/use-anime-catalog'; +import { key, prefetchSession } from '@/services/hooks/auth/use-session'; +import { prefetchCollections } from '@/services/hooks/collections/use-collections'; +import { prefetchLatestComments } from '@/services/hooks/comments/use-latest-comments'; +import { prefetchFollowingHistory } from '@/services/hooks/history/use-following-history'; +import { prefetchAnimeSchedule } from '@/services/hooks/stats/use-anime-schedule'; +import { prefetchWatchList } from '@/services/hooks/watch/use-watch-list'; import getCurrentSeason from '@/utils/get-current-season'; +import getQueryClient from '@/utils/get-query-client'; -interface Props { - queryClient: QueryClient; -} - -const prefetchQueries = async ({ queryClient }: Props) => { +const prefetchQueries = async () => { + const queryClient = getQueryClient(); const season = getCurrentSeason()!; + const year = String(new Date().getFullYear()); + + await prefetchSession(); - const loggedUser: API.User | undefined = queryClient.getQueryData([ - 'loggedUser', - ]); + const loggedUser: API.User | undefined = queryClient.getQueryData(key()); const promises = []; if (loggedUser) { promises.push( - queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: [ - 'watchList', - loggedUser.username, - { - ageRatings: [], - genres: [], - order: 'desc', - seasons: [], - sort: 'watch_score', - statuses: [], - types: [], - studios: [], - watch_status: 'watching', - years: [], - }, - ], - queryFn: ({ pageParam = 1 }) => - getWatchList({ - params: { - username: loggedUser.username, - genres: [], - media_type: [], - rating: [], - season: [], - sort: ['watch_score:desc'], - status: [], - watch_status: 'watching', - }, - page: pageParam, - }), + prefetchWatchList({ + username: loggedUser.username, + watch_status: 'watching', + sort: ['watch_updated:desc'], }), ); promises.push( - queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: ['followingHistory'], - queryFn: ({ pageParam }) => - getFollowingHistory({ - page: pageParam, - }), + prefetchAnimeCatalog({ + season: [season!], + score: [7, 8, 9, 10], + years: [year, year], + page: 1, + iPage: 1, }), ); - } - promises.push( - queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: [ - 'animeSchedule', - { - season, - status: ['ongoing', 'announced'], - year: String(new Date().getFullYear()), - }, - ], - queryFn: ({ pageParam = 1 }) => - getAnimeSchedule({ - params: { - airing_season: [ - season, - String(new Date().getFullYear()), - ], - status: ['ongoing', 'announced'], - }, - page: pageParam, - }), - }), - ); + promises.push(prefetchFollowingHistory()); + } promises.push( - queryClient.prefetchQuery({ - queryKey: ['collections', { page: 1, sort: 'created' }], - queryFn: () => - getCollections({ page: 1, params: { sort: 'created' } }), + prefetchAnimeSchedule({ + airing_season: [season, String(new Date().getFullYear())], + status: ['ongoing', 'announced'], }), ); - promises.push( - queryClient.prefetchQuery({ - queryKey: ['latestComments'], - queryFn: () => getLatestComments(), - }), - ); + promises.push(prefetchCollections({ sort: 'created' })); + promises.push(prefetchLatestComments()); await Promise.all(promises); }; diff --git a/app/(pages)/(home)/page.tsx b/app/(pages)/(home)/page.tsx index 9a134621..caf6d7dd 100644 --- a/app/(pages)/(home)/page.tsx +++ b/app/(pages)/(home)/page.tsx @@ -1,32 +1,27 @@ import { dehydrate } from '@tanstack/query-core'; import { HydrationBoundary } from '@tanstack/react-query'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; import UserCover from '@/components/user-cover'; import Collections from '@/features/home/collections.component'; import Comments from '@/features/home/comments.component'; import History from '@/features/home/history.component'; import Ongoings from '@/features/home/ongoings.component'; -import Profile from '@/features/home/profile.component'; +import Profile from '@/features/home/profile/profile.component'; import Schedule from '@/features/home/schedule/schedule.component'; import prefetchQueries from '@/app/(pages)/(home)/page.queries'; -import getLoggedUserInfo from '@/services/api/user/getLoggedUserInfo'; +import { key } from '@/services/hooks/auth/use-session'; import getQueryClient from '@/utils/get-query-client'; const Page = async () => { - const queryClient = await getQueryClient(); + const queryClient = getQueryClient(); - await queryClient.prefetchQuery({ - queryKey: ['loggedUser'], - queryFn: ({ meta }) => getLoggedUserInfo({}), - }); + await prefetchQueries(); - const loggedUser: API.User | undefined = queryClient.getQueryData([ - 'loggedUser', - ]); - - await prefetchQueries({ queryClient }); + const loggedUser: API.User | undefined = queryClient.getQueryData(key()); const dehydratedState = dehydrate(queryClient); @@ -36,16 +31,22 @@ const Page = async () => { <UserCover /> <Ongoings /> {loggedUser && ( - <div className="grid grid-cols-1 gap-16 lg:grid-cols-2"> - <Profile /> - <History /> - </div> + <Block> + <Header + title="Профіль" + href={`/u/${loggedUser?.username}`} + /> + + <div className="grid grid-cols-1 gap-8 lg:grid-cols-2"> + <Profile /> + <History /> + </div> + </Block> )} + <Comments /> + + <Collections /> <Schedule /> - <div className="grid grid-cols-1 gap-16 lg:grid-cols-2"> - <Collections /> - <Comments /> - </div> </div> </HydrationBoundary> ); diff --git a/app/(pages)/(user)/u/[username]/history/page.tsx b/app/(pages)/(user)/u/[username]/history/page.tsx index 83e34151..67447941 100644 --- a/app/(pages)/(user)/u/[username]/history/page.tsx +++ b/app/(pages)/(user)/u/[username]/history/page.tsx @@ -5,7 +5,7 @@ import { FC } from 'react'; import History from '@/features/users/user-history/user-history.component'; -import getFollowingHistory from '@/services/api/history/getFollowingHistory'; +import { prefetchFollowingHistory } from '@/services/hooks/history/use-following-history'; import { getCookie } from '@/utils/cookies'; import _generateMetadata from '@/utils/generate-metadata'; import getQueryClient from '@/utils/get-query-client'; @@ -19,18 +19,10 @@ interface Props { } const FollowingHistoryPage: FC<Props> = async ({ searchParams }) => { - const queryClient = await getQueryClient(); + const queryClient = getQueryClient(); const auth = await getCookie('auth'); - auth && - (await queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: ['followingHistory'], - queryFn: ({ pageParam, meta }) => - getFollowingHistory({ - page: pageParam, - }), - })); + auth && (await prefetchFollowingHistory()); const dehydratedState = dehydrate(queryClient); diff --git a/app/(pages)/(user)/u/[username]/layout.queries.ts b/app/(pages)/(user)/u/[username]/layout.queries.ts index a69f5da8..5b2c2ae5 100644 --- a/app/(pages)/(user)/u/[username]/layout.queries.ts +++ b/app/(pages)/(user)/u/[username]/layout.queries.ts @@ -1,50 +1,21 @@ -import { QueryClient } from '@tanstack/query-core'; - -import getFollowStats from '@/services/api/follow/getFollowStats'; -import getUserInfo from '@/services/api/user/getUserInfo'; -import getWatchStats from '@/services/api/watch/getWatchStats'; +import { prefetchFollowStats } from '@/services/hooks/follow/use-follow-stats'; +import { prefetchReadStats } from '@/services/hooks/read/use-read-stats'; +import { prefetchUser } from '@/services/hooks/user/use-user'; +import { prefetchWatchStats } from '@/services/hooks/watch/use-watch-stats'; interface Props { - queryClient: QueryClient; params: { username: string; }; } -const prefetchQueries = async ({ - queryClient, - params: { username }, -}: Props) => { +const prefetchQueries = async ({ params: { username } }: Props) => { await Promise.all([ - await queryClient.prefetchQuery({ - queryKey: ['user', username], - queryFn: ({ meta }) => - getUserInfo({ - params: { - username, - }, - }), - }), - - await queryClient.prefetchQuery({ - queryKey: ['watchStats', username], - queryFn: ({ meta }) => - getWatchStats({ - params: { - username, - }, - }), - }), - - await queryClient.prefetchQuery({ - queryKey: ['followStats', username], - queryFn: ({ meta }) => - getFollowStats({ - params: { - username, - }, - }), - }), + prefetchUser({ username }), + prefetchReadStats({ username, content_type: 'manga' }), + prefetchReadStats({ username, content_type: 'novel' }), + prefetchWatchStats({ username }), + prefetchFollowStats({ username }), ]); }; diff --git a/app/(pages)/(user)/u/[username]/layout.tsx b/app/(pages)/(user)/u/[username]/layout.tsx index d528b805..1d4360ca 100644 --- a/app/(pages)/(user)/u/[username]/layout.tsx +++ b/app/(pages)/(user)/u/[username]/layout.tsx @@ -14,7 +14,7 @@ import UserCover from '@/components/user-cover'; import ActivationAlert from '@/features/users/activation-alert.component'; import FollowButton from '@/features/users/follow-button.component'; import FollowStats from '@/features/users/follow-stats.component'; -import ListStats from '@/features/users/list-stats.component'; +import ListStats from '@/features/users/list-stats/list-stats.component'; import UserInfo from '@/features/users/user-info.component'; import UserTitle from '@/features/users/user-title.component'; @@ -38,9 +38,9 @@ export async function generateMetadata( } const UserLayout: FC<Props> = async ({ params: { username }, children }) => { - const queryClient = await getQueryClient(); + const queryClient = getQueryClient(); - await prefetchQueries({ queryClient, params: { username } }); + await prefetchQueries({ params: { username } }); const dehydratedState = dehydrate(queryClient); diff --git a/app/(pages)/(user)/u/[username]/list/[content_type]/page.tsx b/app/(pages)/(user)/u/[username]/list/[content_type]/page.tsx new file mode 100644 index 00000000..68ad471f --- /dev/null +++ b/app/(pages)/(user)/u/[username]/list/[content_type]/page.tsx @@ -0,0 +1,89 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { Metadata, ResolvingMetadata } from 'next'; +import { redirect } from 'next/navigation'; +import { FC } from 'react'; + +import Block from '@/components/ui/block'; + +import ReadFilters from '@/features/filters/read-filters.component'; +import List from '@/features/users/user-readlist/readlist/readlist.component'; +import StatusCombobox from '@/features/users/user-readlist/status-combobox.component'; +import ToolsCombobox from '@/features/users/user-readlist/tools-combobox.component'; +import ViewCombobox from '@/features/users/user-readlist/view-combobox.component'; + +import { prefetchReadList } from '@/services/hooks/read/use-read-list'; +import _generateMetadata from '@/utils/generate-metadata'; +import getQueryClient from '@/utils/get-query-client'; + +export async function generateMetadata( + { params }: { params: { username: string } }, + parent: ResolvingMetadata, +): Promise<Metadata> { + const parentMetadata = await parent; + + return _generateMetadata({ + title: 'Список', + description: parentMetadata.openGraph?.description, + images: parentMetadata.openGraph?.images, + }); +} + +interface Props { + searchParams: { [key: string]: string | string[] | undefined }; + params: { username: string; content_type: string }; +} + +const ListPage: FC<Props> = async ({ + searchParams: { status, sort }, + params: { username, content_type }, +}) => { + if (!status || !sort) { + if (!status) { + redirect( + `/u/${username}/list/${content_type}?status=completed&sort=read_score`, + ); + } + + redirect( + `/u/${username}/list/${content_type}?status=${status}&sort=read_score`, + ); + } + + const queryClient = getQueryClient(); + + await prefetchReadList({ + username, + read_status: status as API.ReadStatus, + sort: [`${sort}:desc`], + content_type: content_type as 'manga' | 'novel', + }); + + const dehydratedState = dehydrate(queryClient); + + return ( + <HydrationBoundary state={dehydratedState}> + <div className="grid grid-cols-1 gap-12 lg:grid-cols-[1fr_25%] lg:gap-16"> + <Block> + <div className="flex items-center justify-between"> + <div className="flex gap-2"> + <StatusCombobox /> + </div> + <div className="flex gap-2"> + <ViewCombobox /> + <ToolsCombobox /> + </div> + </div> + <List /> + </Block> + <div className="sticky top-20 hidden h-fit opacity-60 transition-opacity hover:opacity-100 lg:block"> + <ReadFilters + content_type={content_type as API.ContentType} + sort_type="read" + /> + </div> + </div> + </HydrationBoundary> + ); +}; + +export default ListPage; diff --git a/app/(pages)/(user)/u/[username]/list/page.tsx b/app/(pages)/(user)/u/[username]/list/anime/page.tsx similarity index 54% rename from app/(pages)/(user)/u/[username]/list/page.tsx rename to app/(pages)/(user)/u/[username]/list/anime/page.tsx index f2dd154c..48cf0caa 100644 --- a/app/(pages)/(user)/u/[username]/list/page.tsx +++ b/app/(pages)/(user)/u/[username]/list/anime/page.tsx @@ -1,3 +1,4 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { Metadata, ResolvingMetadata } from 'next'; import { redirect } from 'next/navigation'; import { FC } from 'react'; @@ -10,7 +11,9 @@ import ToolsCombobox from '@/features/users/user-watchlist/tools-combobox.compon import ViewCombobox from '@/features/users/user-watchlist/view-combobox.component'; import List from '@/features/users/user-watchlist/watchlist/watchlist.component'; +import { prefetchWatchList } from '@/services/hooks/watch/use-watch-list'; import _generateMetadata from '@/utils/generate-metadata'; +import getQueryClient from '@/utils/get-query-client'; export async function generateMetadata( { params }: { params: { username: string } }, @@ -30,7 +33,7 @@ interface Props { params: { username: string }; } -const ListPage: FC<Props> = ({ +const ListPage: FC<Props> = async ({ searchParams: { status, sort }, params: { username }, }) => { @@ -42,24 +45,36 @@ const ListPage: FC<Props> = ({ redirect(`/u/${username}/list?status=${status}&sort=watch_score`); } + const queryClient = getQueryClient(); + + await prefetchWatchList({ + username, + watch_status: status as API.WatchStatus, + sort: [`${sort}:desc`], + }); + + const dehydratedState = dehydrate(queryClient); + return ( - <div className="grid grid-cols-1 gap-12 lg:grid-cols-[1fr_25%] lg:gap-16"> - <Block> - <div className="flex items-center justify-between"> - <div className="flex gap-2"> - <StatusCombobox /> - </div> - <div className="flex gap-2"> - <ViewCombobox /> - <ToolsCombobox /> + <HydrationBoundary state={dehydratedState}> + <div className="grid grid-cols-1 gap-12 lg:grid-cols-[1fr_25%] lg:gap-16"> + <Block> + <div className="flex items-center justify-between"> + <div className="flex gap-2"> + <StatusCombobox /> + </div> + <div className="flex gap-2"> + <ViewCombobox /> + <ToolsCombobox /> + </div> </div> + <List /> + </Block> + <div className="sticky top-20 hidden h-fit opacity-60 transition-opacity hover:opacity-100 lg:block"> + <Filters sort_type="watch" content_type="anime" /> </div> - <List /> - </Block> - <div className="sticky top-20 hidden h-fit rounded-md border border-secondary/60 bg-secondary/30 opacity-60 transition-opacity hover:opacity-100 lg:block"> - <Filters className="px-4" type="watchlist" /> </div> - </div> + </HydrationBoundary> ); }; diff --git a/app/(pages)/(user)/u/[username]/page.tsx b/app/(pages)/(user)/u/[username]/page.tsx index dee5f09e..6eedb963 100644 --- a/app/(pages)/(user)/u/[username]/page.tsx +++ b/app/(pages)/(user)/u/[username]/page.tsx @@ -7,9 +7,10 @@ import Favorites from '@/features/users/user-profile/user-favorites/user-favorit import History from '@/features/users/user-profile/user-history/user-history.component'; import Statistics from '@/features/users/user-profile/user-statistics/user-statistics.component'; -import getFavouriteList from '@/services/api/favourite/getFavouriteList'; -import getUserHistory from '@/services/api/history/getUserHistory'; -import getUserActivity from '@/services/api/user/getUserActivity'; +import { prefetchFavorites } from '@/services/hooks/favorite/use-favorites'; +import { prefetchUserHistory } from '@/services/hooks/history/use-user-history'; +import { prefetchUserActivity } from '@/services/hooks/user/use-user-activity'; +import { prefetchUserCollections } from '@/services/hooks/user/use-user-collections'; import getQueryClient from '@/utils/get-query-client'; interface Props { @@ -21,40 +22,12 @@ interface Props { const UserPage: FC<Props> = async ({ params: { username } }) => { const queryClient = await getQueryClient(); - await queryClient.prefetchInfiniteQuery({ - queryKey: ['favorites', username, { content_type: 'anime' }], - queryFn: ({ pageParam = 1, meta }) => - getFavouriteList({ - page: pageParam, - params: { - username, - content_type: 'anime', - }, - }), - initialPageParam: 1, - }); - - await queryClient.prefetchInfiniteQuery({ - queryKey: ['history', username], - queryFn: ({ pageParam, meta }) => - getUserHistory({ - params: { - username, - }, - page: pageParam, - }), - initialPageParam: 1, - }); - - await queryClient.prefetchQuery({ - queryKey: ['activityStats', username], - queryFn: ({ meta }) => - getUserActivity({ - params: { - username, - }, - }), - }); + await Promise.all([ + await prefetchFavorites({ username, content_type: 'anime' }), + await prefetchUserHistory({ username }), + await prefetchUserActivity({ username }), + await prefetchUserCollections({ author: username, sort: 'created' }), + ]); const dehydratedState = dehydrate(queryClient); diff --git a/app/(pages)/collections/(collections)/page.tsx b/app/(pages)/collections/(collections)/page.tsx index 6cae1902..448034d2 100644 --- a/app/(pages)/collections/(collections)/page.tsx +++ b/app/(pages)/collections/(collections)/page.tsx @@ -14,7 +14,10 @@ import Header from '@/components/ui/header'; import CollectionList from '@/features/collections/collection-list/collection-list.component'; import CollectionSort from '@/features/collections/collection-list/collection-sort'; -import getCollections from '@/services/api/collections/getCollections'; +import { + key, + prefetchCollections, +} from '@/services/hooks/collections/use-collections'; import { getCookie } from '@/utils/cookies'; import _generateMetadata from '@/utils/generate-metadata'; import getQueryClient from '@/utils/get-query-client'; @@ -44,16 +47,13 @@ const CollectionsPage: FC<Props> = async ({ searchParams }) => { const queryClient = await getQueryClient(); const auth = await getCookie('auth'); - const collections = await queryClient.fetchQuery({ - queryKey: ['collections', { page: Number(page), sort }], - queryFn: ({ meta }) => - getCollections({ - page: Number(page), - params: { - sort: sort, - }, - }), - }); + const params = { page: Number(page), sort }; + + await prefetchCollections(params); + + const collections: + | API.WithPagination<API.Collection<API.MainContent>> + | undefined = queryClient.getQueryData(key(params)); const dehydratedState = dehydrate(queryClient); diff --git a/app/(pages)/collections/[reference]/page.tsx b/app/(pages)/collections/[reference]/page.tsx index fcde70cd..2f907052 100644 --- a/app/(pages)/collections/[reference]/page.tsx +++ b/app/(pages)/collections/[reference]/page.tsx @@ -12,6 +12,10 @@ import CollectionInfo from '@/features/collections/collection-view/collection-in import CollectionTitle from '@/features/collections/collection-view/collection-title.component'; import getCollection from '@/services/api/collections/getCollection'; +import { + key, + prefetchCollection, +} from '@/services/hooks/collections/use-collection'; import CollectionProvider from '@/services/providers/collection-provider'; import _generateMetadata from '@/utils/generate-metadata'; import getQueryClient from '@/utils/get-query-client'; @@ -45,19 +49,12 @@ const CollectionPage = async ({ }) => { const queryClient = await getQueryClient(); - let collection; + await prefetchCollection({ reference }); - try { - collection = await queryClient.fetchQuery({ - queryKey: ['collection', reference], - queryFn: ({ meta }) => - getCollection({ - params: { - reference, - }, - }), - }); - } catch (e) { + const collection: API.Collection<API.MainContent> | undefined = + queryClient.getQueryData(key({ reference })); + + if (!collection) { return redirect('/collections'); } diff --git a/app/(pages)/comments/[content_type]/[slug]/[[...comment_reference]]/page.tsx b/app/(pages)/comments/[content_type]/[slug]/[[...comment_reference]]/page.tsx index 9ca177d8..9e9e2f2a 100644 --- a/app/(pages)/comments/[content_type]/[slug]/[[...comment_reference]]/page.tsx +++ b/app/(pages)/comments/[content_type]/[slug]/[[...comment_reference]]/page.tsx @@ -5,11 +5,11 @@ import { FC } from 'react'; import ContentHeader from '@/features/comments/comment-content-header.component'; import Content from '@/features/comments/comment-content.component'; -import Comments from '@/features/comments/comment-list/comment-list.component'; -import { getContent } from '@/features/comments/useContent'; +import Comments from '@/features/comments/comment-list.component'; +import { key, prefetchContent } from '@/features/comments/useContent'; -import getCommentThread from '@/services/api/comments/getCommentThread'; -import getComments from '@/services/api/comments/getComments'; +import { prefetchCommentThread } from '@/services/hooks/comments/use-comment-thread'; +import { prefetchComments } from '@/services/hooks/comments/use-comments'; import _generateMetadata from '@/utils/generate-metadata'; import getQueryClient from '@/utils/get-query-client'; @@ -33,49 +33,22 @@ const CommentsPage: FC<Props> = async ({ params }) => { const comment_reference = params.comment_reference && params.comment_reference[0]; - await queryClient.prefetchQuery({ - queryKey: ['content', params.content_type, params.slug], - queryFn: async () => - getContent({ - content_type: params.content_type, - slug: params.slug, - }), - }); + await prefetchContent(params); - const content = queryClient.getQueryData([ - 'content', - params.content_type, - params.slug, - ]); + const content = queryClient.getQueryData(key(params)); if (!content) { return redirect('/'); } !comment_reference && - (await queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: ['comments', params.slug, params.content_type], - queryFn: ({ pageParam, meta }) => - getComments({ - params: { - slug: params.slug, - content_type: params.content_type, - }, - page: pageParam, - }), + (await prefetchComments({ + slug: params.slug, + content_type: params.content_type, })); comment_reference && - (await queryClient.prefetchQuery({ - queryKey: ['commentThread', comment_reference], - queryFn: ({ meta }) => - getCommentThread({ - params: { - reference: comment_reference, - }, - }), - })); + (await prefetchCommentThread({ reference: comment_reference })); return ( <HydrationBoundary state={dehydrate(queryClient)}> diff --git a/app/(pages)/comments/latest/page.tsx b/app/(pages)/comments/latest/page.tsx index 79fc603b..39e5ada8 100644 --- a/app/(pages)/comments/latest/page.tsx +++ b/app/(pages)/comments/latest/page.tsx @@ -5,7 +5,7 @@ import { FC } from 'react'; import Comments from '@/features/comments/latest-comments.component'; -import getGlobalComments from '@/services/api/comments/getGlobalComments'; +import { prefetchGlobalComments } from '@/services/hooks/comments/use-global-comments'; import _generateMetadata from '@/utils/generate-metadata'; import getQueryClient from '@/utils/get-query-client'; @@ -20,14 +20,7 @@ interface Props { const FollowingHistoryPage: FC<Props> = async ({ searchParams }) => { const queryClient = await getQueryClient(); - await queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: ['globalComments'], - queryFn: ({ pageParam, meta }) => - getGlobalComments({ - page: pageParam, - }), - }); + await prefetchGlobalComments(); const dehydratedState = dehydrate(queryClient); diff --git a/app/(pages)/edit/[editId]/layout.tsx b/app/(pages)/edit/[editId]/layout.tsx index 4261fba3..2beeb091 100644 --- a/app/(pages)/edit/[editId]/layout.tsx +++ b/app/(pages)/edit/[editId]/layout.tsx @@ -17,8 +17,9 @@ import Content from '@/features/edit/edit-content/edit-content.component'; import Moderator from '@/features/edit/edit-moderator.component'; import EditStatus from '@/features/edit/edit-status.component'; -import getComments from '@/services/api/comments/getComments'; import getEdit from '@/services/api/edit/getEdit'; +import { prefetchComments } from '@/services/hooks/comments/use-comments'; +import { key, prefetchEdit } from '@/services/hooks/edit/use-edit'; import { EDIT_NAV_ROUTES } from '@/utils/constants'; import _generateMetadata from '@/utils/generate-metadata'; import getQueryClient from '@/utils/get-query-client'; @@ -51,33 +52,18 @@ export async function generateMetadata({ const EditLayout: FC<Props> = async ({ params: { editId }, children }) => { const queryClient = await getQueryClient(); - const edit = await queryClient.fetchQuery({ - queryKey: ['edit', editId], - queryFn: ({ meta }) => - getEdit({ - params: { - edit_id: Number(editId), - }, - }), - }); + await prefetchEdit({ edit_id: Number(editId) }); - await queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: ['comments', editId, 'edit'], - queryFn: ({ pageParam, meta }) => - getComments({ - params: { - slug: editId, - content_type: 'edit', - }, - page: pageParam, - }), - }); + const edit: API.Edit | undefined = queryClient.getQueryData( + key({ edit_id: Number(editId) }), + ); if (!edit) { redirect('/edit'); } + await prefetchComments({ slug: editId, content_type: 'edit' }); + const dehydratedState = dehydrate(queryClient); return ( diff --git a/app/(pages)/edit/[editId]/page.tsx b/app/(pages)/edit/[editId]/page.tsx index 2f0e3d6a..eb7d2212 100644 --- a/app/(pages)/edit/[editId]/page.tsx +++ b/app/(pages)/edit/[editId]/page.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; -import Comments from '@/features/comments/comment-list/comment-list.component'; +import Comments from '@/features/comments/comment-list.component'; import Actions from '@/features/edit/edit-actions/edit-actions.component'; import EditView from '@/features/edit/edit-forms/edit-view-form.component'; diff --git a/app/(pages)/edit/new/page.tsx b/app/(pages)/edit/new/page.tsx index 2d65def1..3d931d78 100644 --- a/app/(pages)/edit/new/page.tsx +++ b/app/(pages)/edit/new/page.tsx @@ -10,9 +10,11 @@ import Content from '@/features/edit/edit-content/edit-content.component'; import EditForm from '@/features/edit/edit-forms/edit-create-form.component'; import RulesAlert from '@/features/edit/edit-rules-alert.component'; -import getAnimeInfo from '@/services/api/anime/getAnimeInfo'; -import getCharacterInfo from '@/services/api/characters/getCharacterInfo'; -import getPersonInfo from '@/services/api/people/getPersonInfo'; +import { prefetchAnimeInfo } from '@/services/hooks/anime/use-anime-info'; +import { prefetchCharacterInfo } from '@/services/hooks/characters/use-character-info'; +import { prefetchMangaInfo } from '@/services/hooks/manga/use-manga-info'; +import { prefetchNovelInfo } from '@/services/hooks/novel/use-novel-info'; +import { prefetchPersonInfo } from '@/services/hooks/people/use-person-info'; import getQueryClient from '@/utils/get-query-client'; interface Props { @@ -22,7 +24,7 @@ interface Props { const EditNewPage: FC<Props> = async ({ searchParams: { content_type, slug }, }) => { - const queryClient = await getQueryClient(); + const queryClient = getQueryClient(); if ( !content_type && @@ -34,39 +36,23 @@ const EditNewPage: FC<Props> = async ({ } if (content_type === 'anime') { - await queryClient.prefetchQuery({ - queryKey: ['anime', slug], - queryFn: ({ meta }) => - getAnimeInfo({ - params: { - slug: String(slug), - }, - }), - }); + await prefetchAnimeInfo({ slug: String(slug) }); + } + + if (content_type === 'manga') { + await prefetchMangaInfo({ slug: String(slug) }); + } + + if (content_type === 'novel') { + await prefetchNovelInfo({ slug: String(slug) }); } if (content_type === 'character') { - await queryClient.prefetchQuery({ - queryKey: ['character', slug], - queryFn: ({ meta }) => - getCharacterInfo({ - params: { - slug: String(slug), - }, - }), - }); + await prefetchCharacterInfo({ slug: String(slug) }); } if (content_type === 'person') { - await queryClient.prefetchQuery({ - queryKey: ['person', slug], - queryFn: ({ meta }) => - getPersonInfo({ - params: { - slug: String(slug), - }, - }), - }); + await prefetchPersonInfo({ slug: String(slug) }); } const content: API.MainContent | undefined = queryClient.getQueryData([ diff --git a/app/(pages)/edit/page.tsx b/app/(pages)/edit/page.tsx index f299a446..d8a31e53 100644 --- a/app/(pages)/edit/page.tsx +++ b/app/(pages)/edit/page.tsx @@ -7,7 +7,6 @@ import Breadcrumbs from '@/components/navigation/nav-breadcrumbs'; import NavMenu from '@/components/navigation/nav-dropdown'; import Block from '@/components/ui/block'; import { Button } from '@/components/ui/button'; -import Card from '@/components/ui/card'; import Header from '@/components/ui/header'; import EditList from '@/features/edit/edit-list/edit-list.component'; @@ -15,8 +14,8 @@ import EditTopStats from '@/features/edit/edit-top-stats/edit-top-stats.componen import Filters from '@/features/filters/edit-filters.component'; import EditFiltersModal from '@/features/modals/edit-filters-modal'; -import getEditList from '@/services/api/edit/getEditList'; -import getEditTop from '@/services/api/stats/edit/getEditTop'; +import { prefetchEditList } from '@/services/hooks/edit/use-edit-list'; +import { prefetchEditTop } from '@/services/hooks/stats/edit/use-edit-top'; import { EDIT_NAV_ROUTES } from '@/utils/constants'; import getQueryClient from '@/utils/get-query-client'; @@ -31,26 +30,14 @@ const EditListPage = async ({ const queryClient = await getQueryClient(); - await queryClient.prefetchQuery({ - queryKey: [ - 'editList', - { - page, - content_type: content_type || null, - order: order || 'desc', - sort: sort || 'edit_id', - edit_status: edit_status || null, - }, - ], - queryFn: ({ meta }) => getEditList({ page: Number(page) }), + await prefetchEditList({ + page: Number(page), + content_type: (content_type as API.ContentType) || undefined, + sort: [`${sort || 'edit_id'}:${order || 'desc'}`], + status: edit_status ? (edit_status as API.EditStatus) : undefined, }); - await queryClient.prefetchInfiniteQuery({ - queryKey: ['editTopStats'], - queryFn: ({ pageParam, meta }) => - getEditTop({ page: Number(pageParam) }), - initialPageParam: 1, - }); + await prefetchEditTop(); const dehydratedState = dehydrate(queryClient); @@ -78,9 +65,9 @@ const EditListPage = async ({ <EditList page={page as string} /> </Block> </div> - <Card className="sticky top-20 order-1 hidden w-full py-0 opacity-60 transition-opacity hover:opacity-100 lg:order-2 lg:block"> + <div className="sticky top-20 order-1 hidden opacity-60 transition-opacity hover:opacity-100 lg:order-2 lg:block"> <Filters /> - </Card> + </div> </div> </div> </HydrationBoundary> diff --git a/app/(pages)/schedule/page.tsx b/app/(pages)/schedule/page.tsx index 6e57ca51..83c2d3a7 100644 --- a/app/(pages)/schedule/page.tsx +++ b/app/(pages)/schedule/page.tsx @@ -13,7 +13,7 @@ import ScheduleFilters from '@/features/filters/schedule-filters.component'; import ScheduleFiltersModal from '@/features/modals/schedule-filters-modal'; import ScheduleList from '@/features/schedule/schedule-list/schedule-list.component'; -import getAnimeSchedule from '@/services/api/stats/getAnimeSchedule'; +import { prefetchAnimeSchedule } from '@/services/hooks/stats/use-anime-schedule'; import _generateMetadata from '@/utils/generate-metadata'; import getCurrentSeason from '@/utils/get-current-season'; import getQueryClient from '@/utils/get-query-client'; @@ -40,18 +40,10 @@ const ScheduleListPage: FC<Props> = async ({ searchParams }) => { ? searchParams.status : ['ongoing', 'announced']; - await queryClient.prefetchInfiniteQuery({ - initialPageParam: 1, - queryKey: ['animeSchedule', { season, status, year, only_watch }], - queryFn: ({ pageParam = 1, meta }) => - getAnimeSchedule({ - page: pageParam, - params: { - status, - only_watch, - airing_season: [season, year], - }, - }), + await prefetchAnimeSchedule({ + status, + only_watch, + airing_season: [season, year], }); const dehydratedState = dehydrate(queryClient); @@ -71,7 +63,7 @@ const ScheduleListPage: FC<Props> = async ({ searchParams }) => { </Button> </ScheduleFiltersModal> </div> - <Card className="hidden w-full opacity-60 transition-opacity hover:opacity-100 lg:block"> + <Card className="hidden w-full lg:block"> <ScheduleFilters /> </Card> </Block> diff --git a/app/globals.css b/app/globals.css index 7f98ade4..e7970834 100644 --- a/app/globals.css +++ b/app/globals.css @@ -104,7 +104,7 @@ } @layer utilities { - .grid-min-1 { + .grid-min-1, { --grid-min: 1rem; } @@ -113,9 +113,9 @@ } .grid-min-3 { - --grid-min: 3rem; + --grid-min: 3.5rem; } - + .grid-min-4 { --grid-min: 4rem; } @@ -159,6 +159,14 @@ .grid-min-18 { --grid-min: 18rem; } + + .grid-min-20 { + --grid-min: 20rem; + } + + .grid-max-3 { + --grid-max: 3.5rem; + } } .logo { diff --git a/app/layout.tsx b/app/layout.tsx index 7032afda..b6b9c5f7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -43,6 +43,8 @@ export const metadata: Metadata = { 'hikka.io', 'хіка іо', 'енциклопедія аніме', + 'енциклопедія манги', + 'енциклопедія ранобе', 'анітуб', 'anitube', 'аніме жанри', @@ -54,6 +56,12 @@ export const metadata: Metadata = { 'аніме портал', 'Аніме Портал', 'аніме культура', + 'манга', + 'манґа', + 'ранобе', + 'читати ранобе', + 'читати мангу', + 'читати манґу', ], metadataBase: new URL('https://hikka.io'), }; diff --git a/components/anime-card.tsx b/components/anime-card.tsx index 36cb26d1..60ddbfda 100644 --- a/components/anime-card.tsx +++ b/components/anime-card.tsx @@ -4,7 +4,7 @@ import ContentCard, { Props as ContentCardProps, } from '@/components/content-card/content-card'; -import { MEDIA_TYPE } from '@/utils/constants'; +import { ANIME_MEDIA_TYPE } from '@/utils/constants'; interface Props extends ContentCardProps { anime: API.Anime | API.AnimeInfo; @@ -18,11 +18,11 @@ const AnimeCard: FC<Props> = ({ anime, ...props }) => { content_type="anime" withContextMenu href={`/anime/${anime.slug}`} - poster={anime.poster} + image={anime.image} title={anime.title} leftSubtitle={anime.year ? String(anime.year) : undefined} rightSubtitle={ - anime.media_type && MEDIA_TYPE[anime.media_type].title_ua + anime.media_type && ANIME_MEDIA_TYPE[anime.media_type].title_ua } {...props} /> diff --git a/components/character-anime-card.tsx b/components/character-anime-card.tsx index 6cbb44e7..68c0d76d 100644 --- a/components/character-anime-card.tsx +++ b/components/character-anime-card.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import { FC } from 'react'; import ContentCard, { @@ -15,7 +14,7 @@ const CharacterAnimeCard: FC<Props> = ({ character, anime, ...props }) => { <ContentCard key={character.slug + anime.slug} href={`/characters/${character.slug}`} - poster={character.image} + image={character.image} title={character.name_ua || character.name_en || character.name_ja} slug={character.slug} withContextMenu @@ -28,7 +27,7 @@ const CharacterAnimeCard: FC<Props> = ({ character, anime, ...props }) => { <div className="absolute bottom-2 right-2 z-[1] flex h-auto w-16 rounded-lg border border-secondary/60 shadow-lg transition-all hover:w-28"> <ContentCard href={`/anime/${anime.slug}`} - poster={anime.poster} + image={anime.image} /> </div> </ContentCard> diff --git a/components/character-card.tsx b/components/character-card.tsx index fcc105f3..6f7bb489 100644 --- a/components/character-card.tsx +++ b/components/character-card.tsx @@ -15,7 +15,7 @@ const CharacterCard: FC<Props> = ({ character, ...props }) => { withContextMenu content_type="character" href={`/characters/${character.slug}`} - poster={character.image} + image={character.image} title={character.name_ua || character.name_en || character.name_ja} {...props} /> diff --git a/features/comments/comment-list/comment-input.tsx b/components/comments/comment-input.tsx similarity index 99% rename from features/comments/comment-list/comment-input.tsx rename to components/comments/comment-input.tsx index 947d3f81..15e4934c 100644 --- a/features/comments/comment-list/comment-input.tsx +++ b/components/comments/comment-input.tsx @@ -53,7 +53,7 @@ const CommentInput: FC<Props> = forwardRef( }); await queryClient.invalidateQueries({ - queryKey: ['commentThread'], + queryKey: ['comment-thread'], exact: false, }); diff --git a/features/comments/comment-list/comment-menu.tsx b/components/comments/comment-menu.tsx similarity index 99% rename from features/comments/comment-list/comment-menu.tsx rename to components/comments/comment-menu.tsx index 98fe9010..d7f9800c 100644 --- a/features/comments/comment-list/comment-menu.tsx +++ b/components/comments/comment-menu.tsx @@ -47,7 +47,7 @@ const CommentMenu: FC<Props> = ({ comment }) => { exact: false, }); queryClient.invalidateQueries({ - queryKey: ['commentThread'], + queryKey: ['comment-thread'], exact: false, }); }, diff --git a/features/comments/comment-list/comment-vote.tsx b/components/comments/comment-vote.tsx similarity index 100% rename from features/comments/comment-list/comment-vote.tsx rename to components/comments/comment-vote.tsx diff --git a/features/comments/comment-list/comment.tsx b/components/comments/comment.tsx similarity index 96% rename from features/comments/comment-list/comment.tsx rename to components/comments/comment.tsx index 805edb41..ff1f11e5 100644 --- a/features/comments/comment-list/comment.tsx +++ b/components/comments/comment.tsx @@ -5,6 +5,9 @@ import { FC, useEffect, useState } from 'react'; import MaterialSymbolsKeyboardArrowDownRounded from '~icons/material-symbols/keyboard-arrow-down-rounded'; import MaterialSymbolsLinkRounded from '~icons/material-symbols/link-rounded'; +import CommentInput from '@/components/comments/comment-input'; +import CommentMenu from '@/components/comments/comment-menu'; +import CommentVote from '@/components/comments/comment-vote'; import MDViewer from '@/components/markdown/viewer/MD-viewer'; import TextExpand from '@/components/text-expand'; import H5 from '@/components/typography/h5'; @@ -13,10 +16,6 @@ import Small from '@/components/typography/small'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; -import CommentInput from '@/features/comments/comment-list/comment-input'; -import CommentMenu from '@/features/comments/comment-list/comment-menu'; -import CommentVote from '@/features/comments/comment-list/comment-vote'; - import { useCommentsContext } from '@/services/providers/comments-provider'; import getDeclensionWord from '@/utils/get-declension-word'; @@ -39,7 +38,7 @@ const Comment: FC<Props> = ({ comment, slug, content_type }) => { const [isInputVisible, setIsInputVisible] = useState<boolean>(false); const loggedUser: API.User | undefined = queryClient.getQueryData([ - 'loggedUser', + 'logged-user', ]); const addReplyInput = () => { diff --git a/features/comments/comment-list/comments.tsx b/components/comments/comments.tsx similarity index 100% rename from features/comments/comment-list/comments.tsx rename to components/comments/comments.tsx diff --git a/components/comments/global-comment.tsx b/components/comments/global-comment.tsx new file mode 100644 index 00000000..9228f977 --- /dev/null +++ b/components/comments/global-comment.tsx @@ -0,0 +1,76 @@ +import { formatDistance } from 'date-fns'; +import Link from 'next/link'; +import { FC } from 'react'; +import BxBxsUpvote from '~icons/bx/bxs-upvote'; + +import H5 from '@/components/typography/h5'; +import Small from '@/components/typography/small'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +import MDViewer from '../markdown/viewer/MD-viewer'; +import { Label } from '../ui/label'; + +interface Props { + comment: API.Comment; + href: string; +} + +const GlobalComment: FC<Props> = ({ comment, href }) => { + return ( + <div + className="flex w-full scroll-mt-20 flex-col gap-2" + id={comment.reference} + > + <div className="flex w-full flex-col items-start gap-2"> + <div className="flex w-full gap-3"> + <Link href={`/u/${comment.author.username}`}> + <Avatar className="w-10 rounded-md"> + <AvatarImage + className="rounded-md" + src={comment.author.avatar} + alt="avatar" + /> + <AvatarFallback className="rounded-md"> + {comment.author.username[0]} + </AvatarFallback> + </Avatar> + </Link> + <div className="flex flex-1 flex-col justify-between"> + <Link + href={`/u/${comment.author.username}`} + className="w-fit" + > + <H5>{comment.author.username}</H5> + </Link> + + <Small className="text-muted-foreground"> + {formatDistance( + comment.created * 1000, + Date.now(), + { addSuffix: true }, + )} + </Small> + </div> + {comment.vote_score > 0 && ( + <div className="flex flex-1 items-start justify-end"> + <div className="flex items-center gap-1"> + <BxBxsUpvote className="size-3 text-success" /> + <Label className="leading-none text-success"> + {comment.vote_score} + </Label> + </div> + </div> + )} + </div> + + <Link href={href} className="hover:underline"> + <MDViewer className="line-clamp-2 text-sm"> + {comment.text} + </MDViewer> + </Link> + </div> + </div> + ); +}; + +export default GlobalComment; diff --git a/components/content-card/anime-tooltip.tsx b/components/content-card/anime-tooltip.tsx index a7192dcd..30806dce 100644 --- a/components/content-card/anime-tooltip.tsx +++ b/components/content-card/anime-tooltip.tsx @@ -18,7 +18,7 @@ import WatchListButton from '@/components/watchlist-button/watchlist-button'; import useAnimeInfo from '@/services/hooks/anime/use-anime-info'; import useSession from '@/services/hooks/auth/use-session'; -import { MEDIA_TYPE, RELEASE_STATUS } from '@/utils/constants'; +import { ANIME_MEDIA_TYPE, RELEASE_STATUS } from '@/utils/constants'; interface TooltipDataProps { slug: string; @@ -85,7 +85,7 @@ const TooltipData: FC<TooltipDataProps> = ({ slug }) => { <div className="flex flex-1 flex-wrap items-center gap-2"> {data.media_type && ( <Label> - {MEDIA_TYPE[data.media_type].title_ua} + {ANIME_MEDIA_TYPE[data.media_type].title_ua} </Label> )} <Badge diff --git a/components/content-card/character-tooltip.tsx b/components/content-card/character-tooltip.tsx index b183fa57..6ab8d2a5 100644 --- a/components/content-card/character-tooltip.tsx +++ b/components/content-card/character-tooltip.tsx @@ -60,7 +60,7 @@ const TooltipData: FC<TooltipDataProps> = ({ slug }) => { <div className="flex w-96 gap-4 text-left"> <ContentCard className="w-20" - poster={data.image} + image={data.image} containerRatio={0.7} href={'/characters/' + data.slug} /> @@ -82,7 +82,7 @@ const TooltipData: FC<TooltipDataProps> = ({ slug }) => { <div className="flex flex-col gap-2"> <ContentCard className="w-10" - poster={characterAnime.anime.poster} + image={characterAnime.anime.image} containerRatio={0.7} href={'/anime/' + characterAnime.anime.slug} /> diff --git a/components/content-card/content-card.tsx b/components/content-card/content-card.tsx index 59cac7ad..a1c4e730 100644 --- a/components/content-card/content-card.tsx +++ b/components/content-card/content-card.tsx @@ -21,6 +21,9 @@ import { cn } from '@/utils/utils'; import { AspectRatio } from '../ui/aspect-ratio'; import ContextMenuOverlay from './context-menu-overlay'; +import MangaTooltip from './manga-tooltip'; +import NovelTooltip from './novel-tooltip'; +import ReadStatus from './read-status'; import WatchStatus from './watch-status'; export interface Props { @@ -29,10 +32,10 @@ export interface Props { description?: string; leftSubtitle?: string; rightSubtitle?: string; - poster?: string | ReactNode; + image?: string | ReactNode; href?: string | UrlObject; containerRatio?: number; - posterClassName?: string; + imageClassName?: string; containerClassName?: string; titleClassName?: string; className?: string; @@ -41,10 +44,11 @@ export interface Props { onClick?: MouseEventHandler<HTMLAnchorElement> & MouseEventHandler<HTMLDivElement>; watch?: API.Watch; + read?: API.Read; slug?: string; content_type?: API.ContentType; withContextMenu?: boolean; - posterProps?: { + imageProps?: { priority?: boolean; }; } @@ -59,6 +63,10 @@ const Tooltip: FC<TooltipProps> = ({ children, content_type, slug }) => { switch (content_type) { case 'anime': return <AnimeTooltip slug={slug}>{children}</AnimeTooltip>; + case 'manga': + return <MangaTooltip slug={slug}>{children}</MangaTooltip>; + case 'novel': + return <NovelTooltip slug={slug}>{children}</NovelTooltip>; case 'character': return <CharacterTooltip slug={slug}>{children}</CharacterTooltip>; case 'person': @@ -72,12 +80,12 @@ const Content = memo( forwardRef<HTMLDivElement, Props>( ( { - poster, + image, title, description, leftSubtitle, rightSubtitle, - posterClassName, + imageClassName, containerClassName, containerRatio, titleClassName, @@ -87,10 +95,11 @@ const Content = memo( disableChildrenLink, onClick, watch, + read, slug, content_type, withContextMenu, - posterProps, + imageProps, ...props }, ref, @@ -120,23 +129,23 @@ const Content = memo( href={href || ''} className="absolute left-0 top-0 flex size-full items-center justify-center rounded-md bg-secondary/60" > - {poster ? ( - typeof poster === 'string' ? ( + {image ? ( + typeof image === 'string' ? ( <Image - src={poster} + src={image} width={150} height={225} className={cn( 'size-full object-cover', - posterClassName, + imageClassName, )} alt="Poster" - {...(posterProps - ? posterProps + {...(imageProps + ? imageProps : { loading: 'lazy' })} /> ) : ( - poster + image ) ) : ( <MaterialSymbolsImageNotSupportedOutlineRounded className="text-4xl text-muted-foreground" /> @@ -144,6 +153,7 @@ const Content = memo( {!disableChildrenLink && children} {watch && <WatchStatus watch={watch} />} + {read && <ReadStatus read={read} />} </Comp> {disableChildrenLink && children} </AspectRatio> diff --git a/components/content-card/manga-tooltip.tsx b/components/content-card/manga-tooltip.tsx new file mode 100644 index 00000000..8146cb0b --- /dev/null +++ b/components/content-card/manga-tooltip.tsx @@ -0,0 +1,172 @@ +'use client'; + +import Link from 'next/link'; +import { FC, PropsWithChildren, memo } from 'react'; + +import MDViewer from '@/components/markdown/viewer/MD-viewer'; +import H5 from '@/components/typography/h5'; +import { Badge } from '@/components/ui/badge'; +import { + HoverCard, + HoverCardArrow, + HoverCardContent, + HoverCardPortal, + HoverCardTrigger, +} from '@/components/ui/hover-card'; +import { Label } from '@/components/ui/label'; + +import useSession from '@/services/hooks/auth/use-session'; +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; +import { MANGA_MEDIA_TYPE, RELEASE_STATUS } from '@/utils/constants'; + +import ReadlistButton from '../readlist-button/readlist-button'; + +interface TooltipDataProps { + slug: string; +} + +interface Props extends PropsWithChildren { + slug?: string; + withTrigger?: boolean; +} + +const TooltipData: FC<TooltipDataProps> = ({ slug }) => { + const { user: loggedUser } = useSession(); + const { data } = useMangaInfo({ slug }); + + if (!data) { + return ( + <div className="flex animate-pulse flex-col gap-4"> + <div className="flex justify-between gap-2"> + <div className="h-4 flex-1 rounded-lg bg-secondary/60" /> + <div className="h-4 w-10 rounded-lg bg-secondary/60" /> + </div> + <div className="flex flex-col gap-2 py-3"> + <div className="h-2 w-full rounded-lg bg-secondary/60" /> + <div className="h-2 w-full rounded-lg bg-secondary/60" /> + <div className="h-2 w-full rounded-lg bg-secondary/60" /> + <div className="h-2 w-full rounded-lg bg-secondary/60" /> + <div className="h-2 w-1/3 rounded-lg bg-secondary/60" /> + </div> + <div className="flex gap-2"> + <div className="h-3 w-1/4 rounded-lg bg-secondary/60" /> + <div className="h-3 flex-1 rounded-lg bg-secondary/60" /> + </div> + <div className="flex gap-2"> + <div className="h-3 w-1/4 rounded-lg bg-secondary/60" /> + <div className="h-3 w-2/4 rounded-lg bg-secondary/60" /> + </div> + <div className="h-12 w-full rounded-md bg-secondary/60" /> + </div> + ); + } + + const synopsis = data.synopsis_ua || data.synopsis_en; + + return ( + <> + <div className="flex flex-col gap-2"> + <div className="flex justify-between gap-2"> + <H5>{data.title}</H5> + {data.score > 0 ? ( + <div className="size-fit rounded-md border border-accent bg-accent px-2 text-sm text-accent-foreground"> + {data.score} + </div> + ) : null} + </div> + {synopsis && ( + <MDViewer className="mb-2 line-clamp-4 text-sm text-muted-foreground"> + {synopsis} + </MDViewer> + )} + <div className="flex items-center"> + <div className="w-1/4"> + <Label className="text-muted-foreground">Тип:</Label> + </div> + <div className="flex flex-1 flex-wrap items-center gap-2"> + {data.media_type && ( + <Label> + {MANGA_MEDIA_TYPE[data.media_type].title_ua} + </Label> + )} + <Badge + variant="status" + bgColor={RELEASE_STATUS[data.status].color} + > + {RELEASE_STATUS[data.status].title_ua} + </Badge> + </div> + </div> + {data.volumes && ( + <div className="flex"> + <div className="w-1/4"> + <Label className="text-muted-foreground"> + Томи: + </Label> + </div> + <div className="flex-1"> + <Label>{data.volumes}</Label> + </div> + </div> + )} + {data.chapters && ( + <div className="flex"> + <div className="w-1/4"> + <Label className="text-muted-foreground"> + Розділи: + </Label> + </div> + <div className="flex-1"> + <Label>{data.chapters}</Label> + </div> + </div> + )} + <div className="flex"> + <div className="w-1/4"> + <Label className="text-muted-foreground">Жанри:</Label> + </div> + <div className="flex-1"> + {data.genres.map((genre, i) => ( + <span key={genre.slug}> + <Link + className="rounded-sm text-sm underline decoration-primary decoration-dashed transition-colors duration-100 hover:bg-primary hover:text-primary-foreground" + href={`/manga?genres=${genre.slug}`} + > + {genre.name_ua} + </Link> + {i + 1 !== data.genres.length && ( + <span>, </span> + )} + </span> + ))} + </div> + </div> + </div> + + {loggedUser && <ReadlistButton slug={slug} content_type="manga" />} + </> + ); +}; + +const MangaTooltip: FC<Props> = ({ slug, children, withTrigger, ...props }) => { + if (!slug) { + return null; + } + + return ( + <HoverCard openDelay={500} closeDelay={100}> + <HoverCardTrigger asChild>{children}</HoverCardTrigger> + <HoverCardPortal> + <HoverCardContent + side="right" + className="hidden w-80 flex-col gap-4 p-4 md:flex" + > + <HoverCardArrow /> + <TooltipData slug={slug} /> + </HoverCardContent> + </HoverCardPortal> + </HoverCard> + ); +}; + +export default memo(MangaTooltip); diff --git a/components/content-card/novel-tooltip.tsx b/components/content-card/novel-tooltip.tsx new file mode 100644 index 00000000..a655492c --- /dev/null +++ b/components/content-card/novel-tooltip.tsx @@ -0,0 +1,172 @@ +'use client'; + +import Link from 'next/link'; +import { FC, PropsWithChildren, memo } from 'react'; + +import MDViewer from '@/components/markdown/viewer/MD-viewer'; +import H5 from '@/components/typography/h5'; +import { Badge } from '@/components/ui/badge'; +import { + HoverCard, + HoverCardArrow, + HoverCardContent, + HoverCardPortal, + HoverCardTrigger, +} from '@/components/ui/hover-card'; +import { Label } from '@/components/ui/label'; + +import useSession from '@/services/hooks/auth/use-session'; +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; +import { NOVEL_MEDIA_TYPE, RELEASE_STATUS } from '@/utils/constants'; + +import ReadlistButton from '../readlist-button/readlist-button'; + +interface TooltipDataProps { + slug: string; +} + +interface Props extends PropsWithChildren { + slug?: string; + withTrigger?: boolean; +} + +const TooltipData: FC<TooltipDataProps> = ({ slug }) => { + const { user: loggedUser } = useSession(); + const { data } = useNovelInfo({ slug }); + + if (!data) { + return ( + <div className="flex animate-pulse flex-col gap-4"> + <div className="flex justify-between gap-2"> + <div className="h-4 flex-1 rounded-lg bg-secondary/60" /> + <div className="h-4 w-10 rounded-lg bg-secondary/60" /> + </div> + <div className="flex flex-col gap-2 py-3"> + <div className="h-2 w-full rounded-lg bg-secondary/60" /> + <div className="h-2 w-full rounded-lg bg-secondary/60" /> + <div className="h-2 w-full rounded-lg bg-secondary/60" /> + <div className="h-2 w-full rounded-lg bg-secondary/60" /> + <div className="h-2 w-1/3 rounded-lg bg-secondary/60" /> + </div> + <div className="flex gap-2"> + <div className="h-3 w-1/4 rounded-lg bg-secondary/60" /> + <div className="h-3 flex-1 rounded-lg bg-secondary/60" /> + </div> + <div className="flex gap-2"> + <div className="h-3 w-1/4 rounded-lg bg-secondary/60" /> + <div className="h-3 w-2/4 rounded-lg bg-secondary/60" /> + </div> + <div className="h-12 w-full rounded-md bg-secondary/60" /> + </div> + ); + } + + const synopsis = data.synopsis_ua || data.synopsis_en; + + return ( + <> + <div className="flex flex-col gap-2"> + <div className="flex justify-between gap-2"> + <H5>{data.title}</H5> + {data.score > 0 ? ( + <div className="size-fit rounded-md border border-accent bg-accent px-2 text-sm text-accent-foreground"> + {data.score} + </div> + ) : null} + </div> + {synopsis && ( + <MDViewer className="mb-2 line-clamp-4 text-sm text-muted-foreground"> + {synopsis} + </MDViewer> + )} + <div className="flex items-center"> + <div className="w-1/4"> + <Label className="text-muted-foreground">Тип:</Label> + </div> + <div className="flex flex-1 flex-wrap items-center gap-2"> + {data.media_type && ( + <Label> + {NOVEL_MEDIA_TYPE[data.media_type].title_ua} + </Label> + )} + <Badge + variant="status" + bgColor={RELEASE_STATUS[data.status].color} + > + {RELEASE_STATUS[data.status].title_ua} + </Badge> + </div> + </div> + {data.volumes && ( + <div className="flex"> + <div className="w-1/4"> + <Label className="text-muted-foreground"> + Томи: + </Label> + </div> + <div className="flex-1"> + <Label>{data.volumes}</Label> + </div> + </div> + )} + {data.chapters && ( + <div className="flex"> + <div className="w-1/4"> + <Label className="text-muted-foreground"> + Розділи: + </Label> + </div> + <div className="flex-1"> + <Label>{data.chapters}</Label> + </div> + </div> + )} + <div className="flex"> + <div className="w-1/4"> + <Label className="text-muted-foreground">Жанри:</Label> + </div> + <div className="flex-1"> + {data.genres.map((genre, i) => ( + <span key={genre.slug}> + <Link + className="rounded-sm text-sm underline decoration-primary decoration-dashed transition-colors duration-100 hover:bg-primary hover:text-primary-foreground" + href={`/novel?genres=${genre.slug}`} + > + {genre.name_ua} + </Link> + {i + 1 !== data.genres.length && ( + <span>, </span> + )} + </span> + ))} + </div> + </div> + </div> + + {loggedUser && <ReadlistButton slug={slug} content_type="novel" />} + </> + ); +}; + +const NovelTooltip: FC<Props> = ({ slug, children, withTrigger, ...props }) => { + if (!slug) { + return null; + } + + return ( + <HoverCard openDelay={500} closeDelay={100}> + <HoverCardTrigger asChild>{children}</HoverCardTrigger> + <HoverCardPortal> + <HoverCardContent + side="right" + className="hidden w-80 flex-col gap-4 p-4 md:flex" + > + <HoverCardArrow /> + <TooltipData slug={slug} /> + </HoverCardContent> + </HoverCardPortal> + </HoverCard> + ); +}; + +export default memo(NovelTooltip); diff --git a/components/content-card/person-tooltip.tsx b/components/content-card/person-tooltip.tsx index 8c52fc6c..572a364a 100644 --- a/components/content-card/person-tooltip.tsx +++ b/components/content-card/person-tooltip.tsx @@ -45,7 +45,7 @@ const PersonAnimeList: FC<{ list?: PersonAnime[]; slug: string }> = ({ className="w-10" href={`/anime/${anime.slug}`} key={anime.slug} - poster={anime.poster} + image={anime.image} slug={anime.slug} content_type={anime.data_type} containerRatio={0.7} @@ -55,7 +55,7 @@ const PersonAnimeList: FC<{ list?: PersonAnime[]; slug: string }> = ({ <ContentCard className="w-10" href={`/people/${slug}`} - poster={ + image={ <MaterialSymbolsMoreHoriz className="text-4xl text-muted-foreground" /> } containerRatio={0.7} @@ -82,7 +82,7 @@ const PersonCharactersList: FC<{ list?: PersonCharacter[]; slug: string }> = ({ className="w-10" href={`/characters/${character.slug}`} key={character.slug} - poster={character.image} + image={character.image} slug={character.slug} content_type={character.data_type} containerRatio={0.7} @@ -92,7 +92,7 @@ const PersonCharactersList: FC<{ list?: PersonCharacter[]; slug: string }> = ({ <ContentCard className="w-10" href={`/people/${slug}`} - poster={ + image={ <MaterialSymbolsMoreHoriz className="text-4xl text-muted-foreground" /> } containerRatio={0.7} @@ -150,7 +150,7 @@ const TooltipData: FC<TooltipDataProps> = ({ slug }) => { <div className="flex w-96 gap-4 text-left"> <ContentCard className="w-20" - poster={data.image} + image={data.image} containerRatio={0.7} href={`/people/${data.slug}`} /> diff --git a/components/content-card/read-status.tsx b/components/content-card/read-status.tsx new file mode 100644 index 00000000..84d189f8 --- /dev/null +++ b/components/content-card/read-status.tsx @@ -0,0 +1,24 @@ +import { FC, createElement } from 'react'; + +import { READ_STATUS } from '@/utils/constants'; + +interface Props { + read: API.Read; +} + +const ReadStatus: FC<Props> = ({ read }) => ( + <div className="absolute left-0 top-0 w-full"> + <div + className="absolute right-2 top-2 z-[1] w-fit rounded-md border-white p-1 text-white" + style={{ + backgroundColor: + READ_STATUS[read.status as API.ReadStatus].color, + }} + > + {createElement(READ_STATUS[read.status as API.ReadStatus].icon!)} + </div> + <div className="absolute left-0 top-0 z-0 h-16 w-full bg-gradient-to-b from-black to-transparent" /> + </div> +); + +export default ReadStatus; diff --git a/components/history-item.tsx b/components/history-item.tsx index 4cf7fc20..77683c1f 100644 --- a/components/history-item.tsx +++ b/components/history-item.tsx @@ -10,6 +10,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; +import { CONTENT_TYPE_LINKS } from '@/utils/constants'; import { convertActivity } from '@/utils/convert-activity'; interface Props { @@ -46,15 +47,24 @@ const HistoryItem: FC<Props> = (props) => { return ( <HorizontalCard title={data.content?.title || 'Загальне'} - href={data.content ? `/anime/${data.content.slug}` : '#'} + href={ + data.content + ? `${CONTENT_TYPE_LINKS[data.content.data_type]}/${data.content.slug}` + : '#' + } description={activity.join(', ')} descriptionClassName="line-clamp-2" - descriptionHref={data.content && `/anime/${data.content.slug}`} + descriptionHref={ + data.content && + `${CONTENT_TYPE_LINKS[data.content.data_type]}/${data.content.slug}` + } createdAt={data.created} image={ - data.content?.poster || ( - <MaterialSymbolsInfoRounded className="flex-1 text-xl text-muted-foreground" /> - ) + data.content?.data_type === 'anime' + ? data.content?.image + : data.content?.image || ( + <MaterialSymbolsInfoRounded className="flex-1 text-xl text-muted-foreground" /> + ) } className={className} > diff --git a/components/manga-card.tsx b/components/manga-card.tsx new file mode 100644 index 00000000..db24a232 --- /dev/null +++ b/components/manga-card.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; + +import ContentCard, { + Props as ContentCardProps, +} from '@/components/content-card/content-card'; + +import { MANGA_MEDIA_TYPE } from '@/utils/constants'; + +interface Props extends ContentCardProps { + manga: API.Manga | API.MangaInfo; +} + +const MangaCard: FC<Props> = ({ manga, ...props }) => { + return ( + <ContentCard + read={manga.read ? manga.read[0] : undefined} + slug={manga.slug} + content_type="manga" + href={`/manga/${manga.slug}`} + image={manga.image} + title={manga.title} + leftSubtitle={manga.year ? String(manga.year) : undefined} + rightSubtitle={ + manga.media_type && MANGA_MEDIA_TYPE[manga.media_type].title_ua + } + {...props} + /> + ); +}; + +export default MangaCard; diff --git a/components/markdown/plate-editor/plate-diff.tsx b/components/markdown/plate-editor/plate-diff.tsx new file mode 100644 index 00000000..a61d8a96 --- /dev/null +++ b/components/markdown/plate-editor/plate-diff.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Value, createPlateEditor } from '@udecode/plate-common'; +import { computeDiff } from '@udecode/plate-diff'; +import { FC, useMemo } from 'react'; + +import PlateEditor from './plate-editor'; +import plugins from './plugins'; +import { deserializeMd } from './serializer-md'; + +interface Props { + current: string; + previous: string; + className?: string; +} + +const PlateDiff: FC<Props> = ({ current, previous, className }) => { + const diffValue = useMemo(() => { + const editor = createPlateEditor({ plugins }); + + return computeDiff( + deserializeMd(editor, previous), + deserializeMd(editor, current), + { + isInline: editor.isInline, + lineBreakChar: '¶', + }, + ) as Value; + }, [previous, current]); + + return ( + <PlateEditor + key={JSON.stringify(diffValue)} + readOnly + disableToolbar + value={diffValue} + className={className} + editorClassName="text-sm p-4" + /> + ); +}; + +export default PlateDiff; diff --git a/components/markdown/plate-editor/plate-editor.tsx b/components/markdown/plate-editor/plate-editor.tsx index 849d3732..ebcf6a70 100644 --- a/components/markdown/plate-editor/plate-editor.tsx +++ b/components/markdown/plate-editor/plate-editor.tsx @@ -46,12 +46,14 @@ export interface EditorProps > { children?: ReactNode; initialValue?: string; - value?: string; + value?: string | Value; updateValue?: boolean; placeholder?: string; onChange?: (value: string) => void; className?: string; + editorClassName?: string; editorRef?: React.MutableRefObject<PlateEditor | null>; + disableToolbar?: boolean; } const setEditorNodes = <N extends EElementOrText<V>, V extends Value = Value>( @@ -80,7 +82,9 @@ const Editor = forwardRef( placeholder, onChange, className, + editorClassName, updateValue, + disableToolbar, ...props }: EditorProps, ref: ForwardedRef<PlateEditor>, @@ -118,43 +122,52 @@ const Editor = forwardRef( <Plate editor={editor} editorRef={editorRef} - value={deserializeValue(value)} + value={ + typeof value === 'string' + ? deserializeValue(value) + : value + } initialValue={deserializeValue(initialValue)} plugins={plugins} onChange={onChange && handleOnChange} {...props} > - <Toolbar className="w-full p-2"> - <ToolbarGroup noSeparator> - <MarkToolbarButton - size="icon-sm" - tooltip="Жирний (⌘+B)" - nodeType={MARK_BOLD} - > - <MaterialSymbolsFormatBoldRounded /> - </MarkToolbarButton> - <MarkToolbarButton - size="icon-sm" - tooltip="Курсив (⌘+I)" - nodeType={MARK_ITALIC} - > - <MaterialSymbolsFormatItalicRounded /> - </MarkToolbarButton> - </ToolbarGroup> - <ToolbarSeparator className="h-full" /> - <ToolbarGroup noSeparator> - <LinkToolbarButton - tooltip="Посилання (⌘+K)" - size="icon-sm" - /> - <SpoilerToolbarButton - tooltip="Спойлер (⌘+Shift+S)" - size="icon-sm" - /> - </ToolbarGroup> - </Toolbar> + {!disableToolbar && ( + <Toolbar className="w-full p-2"> + <ToolbarGroup noSeparator> + <MarkToolbarButton + size="icon-sm" + tooltip="Жирний (⌘+B)" + nodeType={MARK_BOLD} + > + <MaterialSymbolsFormatBoldRounded /> + </MarkToolbarButton> + <MarkToolbarButton + size="icon-sm" + tooltip="Курсив (⌘+I)" + nodeType={MARK_ITALIC} + > + <MaterialSymbolsFormatItalicRounded /> + </MarkToolbarButton> + </ToolbarGroup> + <ToolbarSeparator className="h-full" /> + <ToolbarGroup noSeparator> + <LinkToolbarButton + tooltip="Посилання (⌘+K)" + size="icon-sm" + /> + <SpoilerToolbarButton + tooltip="Спойлер (⌘+Shift+S)" + size="icon-sm" + /> + </ToolbarGroup> + </Toolbar> + )} <PlateContent - className="px-2 pb-2 pt-1 focus:outline-none" + className={cn( + 'px-2 pb-2 pt-1 focus:outline-none', + editorClassName, + )} placeholder={placeholder || 'Напишіть повідомлення...'} /> {children} diff --git a/components/markdown/plate-editor/plate-ui/diff-plugin.tsx b/components/markdown/plate-editor/plate-ui/diff-plugin.tsx new file mode 100644 index 00000000..9469d103 --- /dev/null +++ b/components/markdown/plate-editor/plate-ui/diff-plugin.tsx @@ -0,0 +1,93 @@ +import { createPluginFactory, isInline } from '@udecode/plate-common'; +import { + DiffOperation, + DiffUpdate, + withGetFragmentExcludeDiff, +} from '@udecode/plate-diff'; + +export const MARK_DIFF = 'diff'; + +const diffOperationColors: Record<DiffOperation['type'], string> = { + delete: 'bg-red-200/50', + insert: 'bg-green-200/50', + update: 'bg-blue-200/50', +}; + +const describeUpdate = ({ newProperties, properties }: DiffUpdate) => { + const addedProps: string[] = []; + const removedProps: string[] = []; + const updatedProps: string[] = []; + + Object.keys(newProperties).forEach((key) => { + const oldValue = properties[key]; + const newValue = newProperties[key]; + + if (oldValue === undefined) { + addedProps.push(key); + + return; + } + if (newValue === undefined) { + removedProps.push(key); + + return; + } + + updatedProps.push(key); + }); + + const descriptionParts = []; + + if (addedProps.length > 0) { + descriptionParts.push(`Added ${addedProps.join(', ')}`); + } + if (removedProps.length > 0) { + descriptionParts.push(`Removed ${removedProps.join(', ')}`); + } + if (updatedProps.length > 0) { + updatedProps.forEach((key) => { + descriptionParts.push( + `Updated ${key} from ${properties[key]} to ${newProperties[key]}`, + ); + }); + } + + return descriptionParts.join('\n'); +}; + +export const createDiffPlugin = createPluginFactory({ + inject: { + aboveComponent: + () => + ({ children, editor, element }) => { + if (!element.diff) return children; + + const diffOperation = element.diffOperation as DiffOperation; + + const label = { + delete: 'deletion', + insert: 'insertion', + update: 'update', + }[diffOperation.type]; + + const Component = isInline(editor, element) ? 'span' : 'div'; + + return ( + <Component + aria-label={label} + className={diffOperationColors[diffOperation.type]} + title={ + diffOperation.type === 'update' + ? describeUpdate(diffOperation) + : undefined + } + > + {children} + </Component> + ); + }, + }, + isLeaf: true, + key: MARK_DIFF, + withOverrides: withGetFragmentExcludeDiff, +}); diff --git a/components/markdown/plate-editor/plugins.ts b/components/markdown/plate-editor/plugins.ts index c99bf46e..cac67473 100644 --- a/components/markdown/plate-editor/plugins.ts +++ b/components/markdown/plate-editor/plugins.ts @@ -22,6 +22,7 @@ import { import { withCn } from '@/utils/utils'; +import { createDiffPlugin } from './plate-ui/diff-plugin'; import ItalicLeaf from './plate-ui/italic-leaf'; import LinkElement from './plate-ui/link-element'; import { LinkFloatingToolbar } from './plate-ui/link-floating-toolbar'; @@ -42,6 +43,7 @@ const plugins = createPlugins( createItalicPlugin(), createSpoilerPlugin(), createParagraphPlugin(), + createDiffPlugin(), createListPlugin(), /* createResetNodePlugin({ options: { diff --git a/components/navigation/nav-dropdown.tsx b/components/navigation/nav-dropdown.tsx index 7b0ba989..d8e1d850 100644 --- a/components/navigation/nav-dropdown.tsx +++ b/components/navigation/nav-dropdown.tsx @@ -44,10 +44,13 @@ const Component = ({ } return ( - <NavigationMenu delayDuration={0}> + <NavigationMenu delayDuration={0} skipDelayDuration={0}> <NavigationMenuList> <NavigationMenuItem> - <NavigationMenuTrigger className="max-w-32 sm:max-w-none"> + <NavigationMenuTrigger + onClick={(e) => isDesktop && e.preventDefault()} + className="max-w-32 sm:max-w-none" + > {current && ( <P className="truncate text-sm"> {current.title_ua} diff --git a/components/novel-card.tsx b/components/novel-card.tsx new file mode 100644 index 00000000..32cd571d --- /dev/null +++ b/components/novel-card.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; + +import ContentCard, { + Props as ContentCardProps, +} from '@/components/content-card/content-card'; + +import { NOVEL_MEDIA_TYPE } from '@/utils/constants'; + +interface Props extends ContentCardProps { + novel: API.Novel | API.NovelInfo; +} + +const NovelCard: FC<Props> = ({ novel, ...props }) => { + return ( + <ContentCard + read={novel.read ? novel.read[0] : undefined} + slug={novel.slug} + content_type="novel" + href={`/novel/${novel.slug}`} + image={novel.image} + title={novel.title} + leftSubtitle={novel.year ? String(novel.year) : undefined} + rightSubtitle={ + novel.media_type && NOVEL_MEDIA_TYPE[novel.media_type].title_ua + } + {...props} + /> + ); +}; + +export default NovelCard; diff --git a/components/person-card.tsx b/components/person-card.tsx index d1b99ba0..2bb9fd37 100644 --- a/components/person-card.tsx +++ b/components/person-card.tsx @@ -27,7 +27,7 @@ const PersonCard: FC<Props> = ({ person, roles, ...props }) => { key={person.slug} href={`/people/${person.slug}`} description={getRole(roles)} - poster={person.image} + image={person.image} slug={person.slug} content_type="person" withContextMenu diff --git a/components/readlist-button/new-status-trigger.tsx b/components/readlist-button/new-status-trigger.tsx new file mode 100644 index 00000000..4dad7822 --- /dev/null +++ b/components/readlist-button/new-status-trigger.tsx @@ -0,0 +1,76 @@ +'use client'; + +import * as React from 'react'; +import { FC } from 'react'; +import MaterialSymbolsArrowDropDownRounded from '~icons/material-symbols/arrow-drop-down-rounded'; + +import Planned from '@/components/icons/watch-status/planned'; +import { Button } from '@/components/ui/button'; +import { SelectTrigger } from '@/components/ui/select'; + +import useAddRead from '@/services/hooks/read/use-add-read'; +import { cn } from '@/utils/utils'; + +interface NewStatusTriggerProps { + disabled?: boolean; + slug: string; + content_type: 'novel' | 'manga'; +} + +const NewStatusTrigger: FC<NewStatusTriggerProps> = ({ + disabled, + slug, + content_type, +}) => { + const { mutate: addRead } = useAddRead(); + + const handleAddToPlanned = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + addRead({ + params: { + content_type, + slug, + status: 'planned', + }, + }); + }; + + return ( + <SelectTrigger + asChild + className="gap-0 border-none p-0" + onSelect={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + <div className="flex w-full"> + <Button + variant="secondary" + disabled={disabled} + onClick={handleAddToPlanned} + className={cn( + 'flex-1 flex-nowrap overflow-hidden rounded-r-none', + )} + > + <Planned /> + <span className="truncate rounded-none"> + Додати у список + </span> + </Button> + <Button + variant="secondary" + size="icon" + type="button" + disabled={disabled} + className={cn('rounded-l-none text-xl')} + > + <MaterialSymbolsArrowDropDownRounded /> + </Button> + </div> + </SelectTrigger> + ); +}; + +export default NewStatusTrigger; diff --git a/components/readlist-button/read-status-trigger.tsx b/components/readlist-button/read-status-trigger.tsx new file mode 100644 index 00000000..2b6d2ca6 --- /dev/null +++ b/components/readlist-button/read-status-trigger.tsx @@ -0,0 +1,72 @@ +'use client'; + +import * as React from 'react'; +import { FC, createElement } from 'react'; +import IcBaselineRemoveCircle from '~icons/ic/baseline-remove-circle'; + +import { Button } from '@/components/ui/button'; +import { SelectTrigger } from '@/components/ui/select'; + +import useDeleteRead from '@/services/hooks/read/use-delete-read'; +import { READ_STATUS } from '@/utils/constants'; +import { cn } from '@/utils/utils'; + +interface ReadStatusTriggerProps { + read: API.Read; + content_type: 'novel' | 'manga'; + disabled?: boolean; + slug: string; +} + +const ReadStatusTrigger: FC<ReadStatusTriggerProps> = ({ + read, + content_type, + disabled, + slug, +}) => { + const { mutate: deleteRead } = useDeleteRead(); + + const handleDeleteFromList = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + deleteRead({ params: { slug, content_type } }); + }; + + return ( + <SelectTrigger asChild className="gap-0 border-none p-0"> + <div className="flex w-full"> + <Button + variant="secondary" + disabled={disabled} + className={cn( + 'flex-1 flex-nowrap overflow-hidden rounded-r-none', + )} + > + {createElement(READ_STATUS[read.status].icon!)} + <span className="truncate rounded-none"> + {READ_STATUS[read.status].title_ua || + READ_STATUS[read.status].title_en} + </span> + {read.score > 0 && ( + <> + <span className="opacity-60">-</span> + <span className="opacity-60">{read.score}</span> + </> + )} + </Button> + <Button + variant="secondary" + size="icon" + type="button" + onClick={handleDeleteFromList} + disabled={disabled} + className={cn('rounded-l-none text-xl hover:bg-red-500')} + > + <IcBaselineRemoveCircle /> + </Button> + </div> + </SelectTrigger> + ); +}; + +export default ReadStatusTrigger; diff --git a/components/readlist-button/readlist-button.tsx b/components/readlist-button/readlist-button.tsx new file mode 100644 index 00000000..9b700142 --- /dev/null +++ b/components/readlist-button/readlist-button.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { createElement } from 'react'; +import MaterialSymbolsSettingsOutline from '~icons/material-symbols/settings-outline'; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectList, + SelectSeparator, +} from '@/components/ui/select'; + +import ReadEditModal from '@/features/modals/read-edit-modal'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; +import useAddRead from '@/services/hooks/read/use-add-read'; +import useRead from '@/services/hooks/read/use-read'; +import { useModalContext } from '@/services/providers/modal-provider'; +import { READ_STATUS } from '@/utils/constants'; + +import NewStatusTrigger from './new-status-trigger'; +import ReadStatusTrigger from './read-status-trigger'; + +interface Props { + slug: string; + additional?: boolean; + disabled?: boolean; + content_type: 'novel' | 'manga'; +} + +const SETTINGS_BUTTON = { + label: ( + <div className="flex items-center gap-2"> + <MaterialSymbolsSettingsOutline /> + Налаштування + </div> + ), + value: 'settings', + disableCheckbox: true, + title: 'Налаштування', +}; + +const OPTIONS = [ + ...Object.keys(READ_STATUS).map((status) => ({ + value: status, + title: READ_STATUS[status as API.ReadStatus].title_ua, + label: ( + <div className="flex items-center gap-2"> + {createElement(READ_STATUS[status as API.ReadStatus].icon!)} + {READ_STATUS[status as API.ReadStatus].title_ua} + </div> + ), + })), +]; + +const Component = ({ slug, content_type, disabled }: Props) => { + const { openModal } = useModalContext(); + + const { data: read, isError: readError } = useRead( + { + slug, + content_type, + }, + { enabled: !disabled }, + ); + const { data: manga } = useMangaInfo( + { + slug, + }, + { enabled: !disabled && content_type === 'manga' }, + ); + const { mutate: addRead } = useAddRead(); + + const openReadEditModal = () => { + if (manga) { + openModal({ + content: ( + <ReadEditModal + content_type={content_type} + slug={manga.slug} + /> + ), + className: '!max-w-xl', + title: manga.title, + forceModal: true, + }); + } + }; + + const handleChangeStatus = (options: string[]) => { + const params = + read && !readError + ? { + volumes: read?.volumes || undefined, + score: read?.score || undefined, + note: read?.note || undefined, + rereads: read?.rereads || undefined, + } + : {}; + + if (options[0] === 'settings') { + openReadEditModal(); + return; + } + + if (options[0] === 'completed') { + addRead({ + params: { + slug, + content_type, + status: 'completed', + ...params, + chapters: manga?.chapters, + }, + }); + } else { + addRead({ + params: { + slug, + content_type, + status: options[0] as API.ReadStatus, + ...params, + }, + }); + } + }; + + return ( + <Select + value={read && !readError ? [read.status] : []} + onValueChange={handleChangeStatus} + > + {read && !readError ? ( + <ReadStatusTrigger + content_type={content_type} + read={read!} + disabled={disabled} + slug={slug} + /> + ) : ( + <NewStatusTrigger + content_type={content_type} + slug={slug} + disabled={disabled} + /> + )} + + <SelectContent> + <SelectList> + <SelectGroup> + {OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectGroup> + {read && !readError && <SelectSeparator />} + {read && !readError && ( + <SelectGroup> + {read && !readError && ( + <SelectItem disableCheckbox value="settings"> + {SETTINGS_BUTTON.label} + </SelectItem> + )} + </SelectGroup> + )} + </SelectList> + </SelectContent> + </Select> + ); +}; + +export default Component; diff --git a/components/text-expand.tsx b/components/text-expand.tsx index 05faa427..c1579ea9 100644 --- a/components/text-expand.tsx +++ b/components/text-expand.tsx @@ -1,5 +1,5 @@ -import React, { - PropsWithChildren, +import { + ComponentPropsWithoutRef, memo, useEffect, useRef, @@ -10,13 +10,26 @@ import { Button } from '@/components/ui/button'; import { cn } from '@/utils/utils'; -const TextExpand = ({ children }: PropsWithChildren) => { +interface Props extends ComponentPropsWithoutRef<'div'> { + expanded?: boolean; + setExpanded?: (expanded: boolean) => void; +} + +const TextExpand = ({ + children, + expanded: _expanded, + setExpanded: _setExpanded, + className, +}: Props) => { const ref = useRef<HTMLDivElement>(null); const [isExpanded, setIsExpanded] = useState(false); + const expanded = _expanded ? _expanded : isExpanded; + const setExpanded = _setExpanded ? _setExpanded : setIsExpanded; + useEffect(() => { if (ref.current) { - setIsExpanded(!(ref.current.scrollHeight > 216)); + setExpanded(!(ref.current.scrollHeight > 216)); } }, []); @@ -26,20 +39,21 @@ const TextExpand = ({ children }: PropsWithChildren) => { ref={ref} className={cn( 'relative overflow-hidden', - !isExpanded && 'unexpanded-text max-h-52', + !expanded && 'unexpanded-text max-h-52', + !expanded && className, )} > {children} </div> - {!isExpanded && ( + {!expanded && ( <div className="flex w-full items-center"> <Button variant="link" size="sm" className="p-0" - onClick={() => setIsExpanded(!isExpanded)} + onClick={() => setExpanded(!expanded)} > - {isExpanded ? 'Згорнути...' : 'Показати більше...'} + {expanded ? 'Згорнути...' : 'Показати більше...'} </Button> </div> )} diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx index 86ab87d8..e2bc3bfb 100644 --- a/components/ui/collapsible.tsx +++ b/components/ui/collapsible.tsx @@ -8,4 +8,4 @@ const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; -export { Collapsible, CollapsibleTrigger, CollapsibleContent }; +export { Collapsible, CollapsibleContent, CollapsibleTrigger }; diff --git a/components/ui/command.tsx b/components/ui/command.tsx index cb6d6665..5e5551ea 100644 --- a/components/ui/command.tsx +++ b/components/ui/command.tsx @@ -27,6 +27,7 @@ Command.displayName = CommandPrimitive.displayName; interface CommandDialogProps extends DialogProps { className?: string; containerClassName?: string; + overlayClassName?: string; shouldFilter?: boolean; } @@ -34,12 +35,14 @@ const CommandDialog = ({ children, className, containerClassName, + overlayClassName, shouldFilter, ...props }: CommandDialogProps) => { return ( <Dialog {...props}> <DialogContent + overlayClassName={overlayClassName} className={cn('overflow-hidden p-0 shadow-lg', className)} containerClassName={containerClassName} > diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 83e572ae..d8e1bc13 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< <DialogPrimitive.Overlay ref={ref} className={cn( - 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', + 'fixed inset-0 z-50 grid place-items-center overflow-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className, )} {...props} @@ -33,33 +33,33 @@ const DialogContent = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { containerClassName?: string; + overlayClassName?: string; } ->(({ className, children, containerClassName, ...props }, ref) => ( - <DialogPortal> - <DialogOverlay /> - <DialogPrimitive.Content - ref={ref} - className={cn( - 'fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]', - className, - )} - {...props} - > - <div - className={cn( - 'relative mx-4 grid flex-1 gap-4 overflow-hidden rounded-md border bg-background p-6 shadow-lg', - containerClassName, - )} - > - {children} - <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-0 focus:ring-ring focus:ring-offset-0 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> - <X className="size-4" /> - <span className="sr-only">Close</span> - </DialogPrimitive.Close> - </div> - </DialogPrimitive.Content> - </DialogPortal> -)); +>( + ( + { className, children, containerClassName, overlayClassName, ...props }, + ref, + ) => ( + <DialogPortal> + <DialogOverlay className={overlayClassName}> + <DialogPrimitive.Content + ref={ref} + className={cn( + 'relative z-50 grid w-full max-w-lg gap-4 overflow-hidden border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full', + className, + )} + {...props} + > + {children} + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="size-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogOverlay> + </DialogPortal> + ), +); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ diff --git a/components/ui/horizontal-card.tsx b/components/ui/horizontal-card.tsx index b480fe46..18f09097 100644 --- a/components/ui/horizontal-card.tsx +++ b/components/ui/horizontal-card.tsx @@ -53,7 +53,7 @@ const HorizontalCard: FC<Props> = ({ containerClassName={cn(imageClassName)} containerRatio={imageRatio} href={href} - poster={image} + image={image} > {imageChildren} </ContentCard> diff --git a/components/ui/horizontal-content-card.tsx b/components/ui/horizontal-content-card.tsx index a6e98cf7..8dc4357d 100644 --- a/components/ui/horizontal-content-card.tsx +++ b/components/ui/horizontal-content-card.tsx @@ -1,5 +1,4 @@ import Link from 'next/link'; -import * as React from 'react'; import { ComponentPropsWithoutRef, forwardRef, memo } from 'react'; import ContentCard from '@/components/content-card/content-card'; @@ -48,7 +47,7 @@ const HorizontalContentCard = forwardRef<HTMLDivElement, Props>( size === 'sm' && 'max-w-16', )} containerClassName="rounded-r-none" - poster={image} + image={image} href={href} /> <div className="flex w-full flex-col justify-between gap-2 p-4"> diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 4f225be6..36bcf9b5 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -243,7 +243,9 @@ const SelectTrigger = React.forwardRef< buttonVariants({ variant: 'outline', size: 'default' }), !asChild && 'flex h-auto min-h-12 items-center justify-between', - disabled ? 'cursor-not-allowed opacity-50' : 'cursor-text', + disabled + ? 'cursor-not-allowed opacity-50' + : 'cursor-pointer', className, )} onClick={disabled ? PreventClick : props.onClick} @@ -304,7 +306,7 @@ const SelectValue = React.forwardRef< <span className="pointer-events-none text-muted-foreground"> {placeholder} </span> - <SelectIcon /> + <SelectIcon className="!size-4" /> </Fragment> ); } @@ -317,7 +319,7 @@ const SelectValue = React.forwardRef< <span className="pointer-events-none truncate"> {item?.label || value[0]} </span> - <SelectIcon /> + <SelectIcon className="!size-4" /> </Fragment> ); } @@ -690,16 +692,16 @@ const groupOptions = (options: Option[]) => { export { Select, - SelectTrigger, - SelectValue, - SelectSearch, SelectContent, - SelectList, - SelectItem, - SelectGroup, - SelectSeparator, SelectEmpty, + SelectGroup, SelectIcon, + SelectItem, + SelectList, + SelectSearch, + SelectSeparator, + SelectTrigger, + SelectValue, groupOptions, renderSelectOptions, }; diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx index 2cb6dd10..195a93c1 100644 --- a/components/ui/sheet.tsx +++ b/components/ui/sheet.tsx @@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef< SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; const sheetVariants = cva( - 'fixed z-50 gap-4 bg-background p-6 border m-4 !h-auto rounded-lg shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + 'fixed z-50 gap-4 bg-background p-6 border sm:m-4 !h-auto sm:rounded-lg shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', { variants: { side: { diff --git a/components/ui/stack.tsx b/components/ui/stack.tsx index 2a8cbdb3..fb438020 100644 --- a/components/ui/stack.tsx +++ b/components/ui/stack.tsx @@ -20,8 +20,8 @@ const SIZES = { const EXTENDED_SIZES = { 3: 'grid-cols-2 md:grid-cols-3', 4: 'grid-cols-2 md:grid-cols-4', - 5: 'grid-cols-2 md:grid-cols-5', - 6: 'grid-cols-2 md:grid-cols-6', + 5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-5', + 6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-6', }; const Stack: FC<Props> = ({ diff --git a/components/voice-card.tsx b/components/voice-card.tsx index 56733a62..c4300cbe 100644 --- a/components/voice-card.tsx +++ b/components/voice-card.tsx @@ -15,7 +15,7 @@ const VoiceCard: FC<Props> = ({ person, anime, language, ...props }) => { <ContentCard key={person.slug + anime.slug} href={`/people/${person.slug}`} - poster={person.image} + image={person.image} title={person.name_ua || person.name_en || person.name_native} description={anime.title} disableChildrenLink @@ -29,7 +29,7 @@ const VoiceCard: FC<Props> = ({ person, anime, language, ...props }) => { <div className="absolute bottom-2 right-2 z-[1] flex h-auto w-16 rounded-lg border border-secondary/60 shadow-lg transition-all hover:w-28"> <ContentCard href={`/anime/${anime.slug}`} - poster={anime.poster} + image={anime.image} /> </div> </ContentCard> diff --git a/components/watchlist-button/new-status-trigger.tsx b/components/watchlist-button/new-status-trigger.tsx index 21638995..fa630cd3 100644 --- a/components/watchlist-button/new-status-trigger.tsx +++ b/components/watchlist-button/new-status-trigger.tsx @@ -1,3 +1,5 @@ +'use client'; + import * as React from 'react'; import { FC } from 'react'; import MaterialSymbolsArrowDropDownRounded from '~icons/material-symbols/arrow-drop-down-rounded'; @@ -6,23 +8,25 @@ import Planned from '@/components/icons/watch-status/planned'; import { Button } from '@/components/ui/button'; import { SelectTrigger } from '@/components/ui/select'; +import useAddWatch from '@/services/hooks/watch/use-add-watch'; import { cn } from '@/utils/utils'; interface NewStatusTriggerProps { disabled?: boolean; - - addToList(args: { status: API.WatchStatus }): void; + slug: string; } -const NewStatusTrigger: FC<NewStatusTriggerProps> = ({ - disabled, - addToList, -}) => { +const NewStatusTrigger: FC<NewStatusTriggerProps> = ({ disabled, slug }) => { + const { mutate: addWatch } = useAddWatch(); + const handleAddToPlanned = (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); e.stopPropagation(); - addToList({ - status: 'planned', + addWatch({ + params: { + slug, + status: 'planned', + }, }); }; diff --git a/components/watchlist-button/watch-status-trigger.tsx b/components/watchlist-button/watch-status-trigger.tsx index 3d90f28d..25a31424 100644 --- a/components/watchlist-button/watch-status-trigger.tsx +++ b/components/watchlist-button/watch-status-trigger.tsx @@ -1,3 +1,5 @@ +'use client'; + import * as React from 'react'; import { FC, createElement } from 'react'; import IcBaselineRemoveCircle from '~icons/ic/baseline-remove-circle'; @@ -5,25 +7,27 @@ import IcBaselineRemoveCircle from '~icons/ic/baseline-remove-circle'; import { Button } from '@/components/ui/button'; import { SelectTrigger } from '@/components/ui/select'; +import useDeleteWatch from '@/services/hooks/watch/use-delete-watch'; import { WATCH_STATUS } from '@/utils/constants'; import { cn } from '@/utils/utils'; interface WatchStatusTriggerProps { watch: API.Watch; disabled?: boolean; - - deleteFromList(): void; + slug: string; } const WatchStatusTrigger: FC<WatchStatusTriggerProps> = ({ watch, disabled, - deleteFromList, + slug, }) => { + const { mutate: deleteWatch } = useDeleteWatch(); + const handleDeleteFromList = (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); e.stopPropagation(); - deleteFromList(); + deleteWatch({ params: { slug } }); }; return ( diff --git a/components/watchlist-button/watchlist-button.tsx b/components/watchlist-button/watchlist-button.tsx index 503b2f26..ca7a080f 100644 --- a/components/watchlist-button/watchlist-button.tsx +++ b/components/watchlist-button/watchlist-button.tsx @@ -15,8 +15,7 @@ import { import WatchEditModal from '@/features/modals/watch-edit-modal'; import useAnimeInfo from '@/services/hooks/anime/use-anime-info'; -import useAddToList from '@/services/hooks/watch/use-add-to-list'; -import useDeleteFromList from '@/services/hooks/watch/use-delete-from-list'; +import useAddWatch from '@/services/hooks/watch/use-add-watch'; import useWatch from '@/services/hooks/watch/use-watch'; import { useModalContext } from '@/services/providers/modal-provider'; import { WATCH_STATUS } from '@/utils/constants'; @@ -55,7 +54,7 @@ const OPTIONS = [ })), ]; -const Component = ({ slug, additional, disabled }: Props) => { +const Component = ({ slug, disabled }: Props) => { const { openModal } = useModalContext(); const { data: watch, isError: watchError } = useWatch( @@ -70,8 +69,7 @@ const Component = ({ slug, additional, disabled }: Props) => { }, { enabled: !disabled }, ); - const { mutate: addToList } = useAddToList({ slug }); - const { mutate: deleteFromList } = useDeleteFromList({ slug }); + const { mutate: addWatch } = useAddWatch(); const openWatchEditModal = () => { if (anime) { @@ -101,15 +99,21 @@ const Component = ({ slug, additional, disabled }: Props) => { } if (options[0] === 'completed') { - addToList({ - status: 'completed', - ...params, - episodes: anime?.episodes_total, + addWatch({ + params: { + slug, + status: 'completed', + ...params, + episodes: anime?.episodes_total, + }, }); } else { - addToList({ - status: options[0] as API.WatchStatus, - ...params, + addWatch({ + params: { + slug, + status: options[0] as API.WatchStatus, + ...params, + }, }); } }; @@ -123,10 +127,10 @@ const Component = ({ slug, additional, disabled }: Props) => { <WatchStatusTrigger watch={watch!} disabled={disabled} - deleteFromList={deleteFromList} + slug={slug} /> ) : ( - <NewStatusTrigger disabled={disabled} addToList={addToList} /> + <NewStatusTrigger slug={slug} disabled={disabled} /> )} <SelectContent> diff --git a/features/anime/anime-list-navbar/anime-list-navbar.component.tsx b/features/anime/anime-list-navbar/anime-list-navbar.component.tsx index 2389f197..ce52569a 100644 --- a/features/anime/anime-list-navbar/anime-list-navbar.component.tsx +++ b/features/anime/anime-list-navbar/anime-list-navbar.component.tsx @@ -17,7 +17,7 @@ const AnimeListNavbar = () => { <Search /> </Suspense> <div className="lg:hidden"> - <FiltersModal type="anime" /> + <FiltersModal sort_type="anime" /> </div> </div> ); diff --git a/features/anime/anime-list/anime-list.component.tsx b/features/anime/anime-list/anime-list.component.tsx index d584f66b..9b1547ee 100644 --- a/features/anime/anime-list/anime-list.component.tsx +++ b/features/anime/anime-list/anime-list.component.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useSearchParams } from 'next/navigation'; import { FC } from 'react'; import AnimeCard from '@/components/anime-card'; @@ -14,15 +15,39 @@ import useAnimeCatalog from '@/services/hooks/anime/use-anime-catalog'; import AnimeListSkeleton from './anime-list-skeleton'; import { useNextPage, useUpdatePage } from './anime-list.hooks'; -interface Props { - searchParams: Record<string, string>; -} +interface Props {} -const AnimeList: FC<Props> = ({ searchParams }) => { - const page = searchParams.page; - const iPage = searchParams.iPage; +const AnimeList: FC<Props> = () => { + const searchParams = useSearchParams(); + + const query = searchParams.get('search'); + const media_type = searchParams.getAll('types'); + const status = searchParams.getAll('statuses'); + const season = searchParams.getAll('seasons'); + const rating = searchParams.getAll('ratings'); + const years = searchParams.getAll('years'); + const genres = searchParams.getAll('genres'); + const studios = searchParams.getAll('studios'); + + const only_translated = searchParams.get('only_translated'); + + const sort = searchParams.get('sort') || 'score'; + const order = searchParams.get('order') || 'desc'; + + const page = searchParams.get('page'); + const iPage = searchParams.get('iPage'); const dataKeys = { + query, + media_type, + status, + season, + rating, + years, + genres, + studios, + only_translated: Boolean(only_translated), + sort: sort ? [`${sort}:${order}`] : undefined, page: Number(page), iPage: Number(iPage), }; diff --git a/features/anime/anime-list/anime-list.hooks.ts b/features/anime/anime-list/anime-list.hooks.ts index 2c69b91a..df1e2b03 100644 --- a/features/anime/anime-list/anime-list.hooks.ts +++ b/features/anime/anime-list/anime-list.hooks.ts @@ -15,7 +15,7 @@ export const useUpdatePage = ({ page, iPage }: Props) => { return (newPage: number) => { if (newPage !== Number(page) || newPage !== Number(iPage)) { queryClient.removeQueries({ - queryKey: ['list', page, {}], + queryKey: ['anime-list', page, {}], exact: false, }); const query = createQueryString( diff --git a/features/anime/anime-view/cover.component.tsx b/features/anime/anime-view/cover.component.tsx index b0dea226..302d57a3 100644 --- a/features/anime/anime-view/cover.component.tsx +++ b/features/anime/anime-view/cover.component.tsx @@ -14,10 +14,7 @@ const Cover: FC = () => { return ( <div className="flex items-center px-16 md:px-48 lg:px-0"> - <ContentCard - posterProps={{ priority: true }} - poster={anime?.poster} - > + <ContentCard imageProps={{ priority: true }} image={anime?.image}> <div className="absolute bottom-2 right-2 z-[1]"> <FavoriteButton slug={String(params.slug)} diff --git a/features/anime/anime-view/details/media-type.tsx b/features/anime/anime-view/details/media-type.tsx index b5efdfd9..13b075e5 100644 --- a/features/anime/anime-view/details/media-type.tsx +++ b/features/anime/anime-view/details/media-type.tsx @@ -2,10 +2,10 @@ import { FC } from 'react'; import { Label } from '@/components/ui/label'; -import { MEDIA_TYPE } from '@/utils/constants'; +import { ANIME_MEDIA_TYPE } from '@/utils/constants'; interface Props { - media_type: API.MediaType; + media_type: API.AnimeMediaType; } const MediaType: FC<Props> = ({ media_type }) => { @@ -19,7 +19,7 @@ const MediaType: FC<Props> = ({ media_type }) => { <Label className="text-muted-foreground">Тип:</Label> </div> <div className="flex-1"> - <Label>{MEDIA_TYPE[media_type].title_ua}</Label> + <Label>{ANIME_MEDIA_TYPE[media_type].title_ua}</Label> </div> </div> ); diff --git a/features/anime/anime-view/followings/followings-modal.tsx b/features/anime/anime-view/followings/followings-modal.tsx index bddb2d07..bfb990a3 100644 --- a/features/anime/anime-view/followings/followings-modal.tsx +++ b/features/anime/anime-view/followings/followings-modal.tsx @@ -15,28 +15,25 @@ const FollowingsModal = () => { useFollowingWatchList({ slug: String(params.slug) }); return ( - <> - <hr className="-mx-6 mt-4 h-px w-auto bg-border" /> - <div className="-mx-6 h-full w-auto flex-1 overflow-y-scroll"> - {list && - list.map((item) => ( - <FollowingItem - className="px-6 py-4" - data={item} - key={item.reference} - /> - ))} - {hasNextPage && ( - <div className="px-4"> - <LoadMoreButton - isFetchingNextPage={isFetchingNextPage} - fetchNextPage={fetchNextPage} - ref={ref} - /> - </div> - )} - </div> - </> + <div className="h-full w-auto flex-1 overflow-y-scroll"> + {list && + list.map((item) => ( + <FollowingItem + className="px-6 py-4" + data={item} + key={item.reference} + /> + ))} + {hasNextPage && ( + <div className="px-6"> + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + </div> + )} + </div> ); }; diff --git a/features/anime/anime-view/franchise.component.tsx b/features/anime/anime-view/franchise.component.tsx index 5f29a170..ed9389fd 100644 --- a/features/anime/anime-view/franchise.component.tsx +++ b/features/anime/anime-view/franchise.component.tsx @@ -4,13 +4,14 @@ import { useParams } from 'next/navigation'; import { FC } from 'react'; import AnimeCard from '@/components/anime-card'; -import LoadMoreButton from '@/components/load-more-button'; +import MangaCard from '@/components/manga-card'; +import NovelCard from '@/components/novel-card'; import Block from '@/components/ui/block'; import Header from '@/components/ui/header'; import Stack from '@/components/ui/stack'; import useAnimeInfo from '@/services/hooks/anime/use-anime-info'; -import useFranchise from '@/services/hooks/anime/use-franchise'; +import useFranchise from '@/services/hooks/related/use-franchise'; interface Props { extended?: boolean; @@ -20,19 +21,20 @@ const Franchise: FC<Props> = ({ extended }) => { const params = useParams(); const { data: anime } = useAnimeInfo({ slug: String(params.slug) }); - const { list, fetchNextPage, hasNextPage, isFetchingNextPage, ref } = - useFranchise({ slug: String(params.slug) }); + const { data: franchise } = useFranchise({ + slug: String(params.slug), + content_type: 'anime', + }); if (!anime || !anime.has_franchise) { return null; } - if (!list || list.length === 0) { + if (!franchise) { return null; } - const filterSelfData = list.filter((anime) => anime.slug !== params.slug); - const filteredData = extended ? filterSelfData : filterSelfData.slice(0, 4); + const filteredData = extended ? franchise.list : franchise.list.slice(0, 4); return ( <Block> @@ -41,17 +43,20 @@ const Franchise: FC<Props> = ({ extended }) => { href={!extended ? params.slug + '/franchise' : undefined} /> <Stack extended={extended} size={4} className="grid-min-10"> - {filteredData.map((anime) => ( - <AnimeCard key={anime.slug} anime={anime} /> - ))} + {filteredData.map((content) => { + if (content.data_type === 'anime') { + return <AnimeCard key={content.slug} anime={content} />; + } + + if (content.data_type === 'manga') { + return <MangaCard key={content.slug} manga={content} />; + } + + if (content.data_type === 'novel') { + return <NovelCard key={content.slug} novel={content} />; + } + })} </Stack> - {extended && hasNextPage && ( - <LoadMoreButton - isFetchingNextPage={isFetchingNextPage} - fetchNextPage={fetchNextPage} - ref={ref} - /> - )} </Block> ); }; diff --git a/features/anime/anime-view/links/links-modal.tsx b/features/anime/anime-view/links/links-modal.tsx deleted file mode 100644 index 220515b6..00000000 --- a/features/anime/anime-view/links/links-modal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client'; - -import { useParams } from 'next/navigation'; - -import P from '@/components/typography/p'; -import { Badge } from '@/components/ui/badge'; -import HorizontalCard from '@/components/ui/horizontal-card'; - -import useAnimeInfo from '@/services/hooks/anime/use-anime-info'; -import { cn } from '@/utils/utils'; - -const LinksModal = () => { - const params = useParams(); - const { data: anime } = useAnimeInfo({ slug: String(params.slug) }); - - if (!anime) { - return null; - } - - return ( - <> - <hr className="-mx-6 mt-4 h-px w-auto bg-border" /> - <div className="-mx-6 h-full w-auto flex-1 overflow-y-scroll"> - {anime.external && - anime.external.map((link) => ( - <HorizontalCard - className="px-6 py-4" - key={link.url} - title={link.text} - description={link.url} - descriptionHref={link.url} - href={link.url} - imageRatio={1} - imageContainerClassName="w-10" - descriptionClassName="break-all" - image={<P>{link.text[0]}</P>} - > - <Badge - className={cn( - 'bg-warning text-warning-foreground', - link.type === 'general' && - 'bg-success text-success-foreground', - )} - variant="status" - > - {link.type === 'general' - ? 'Загальне' - : 'Перегляд'} - </Badge> - </HorizontalCard> - ))} - </div> - </> - ); -}; - -export default LinksModal; diff --git a/features/anime/anime-view/links/links.component.tsx b/features/anime/anime-view/links/links.component.tsx index 1d9c40e7..93e39a61 100644 --- a/features/anime/anime-view/links/links.component.tsx +++ b/features/anime/anime-view/links/links.component.tsx @@ -5,6 +5,7 @@ import { FC, useState } from 'react'; import MaterialSymbolsInfoIRounded from '~icons/material-symbols/info-i-rounded'; import MaterialSymbolsPlayArrowRounded from '~icons/material-symbols/play-arrow-rounded'; +import TextExpand from '@/components/text-expand'; import P from '@/components/typography/p'; import Block from '@/components/ui/block'; import Header from '@/components/ui/header'; @@ -14,13 +15,12 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import useAnimeInfo from '@/services/hooks/anime/use-anime-info'; import { useModalContext } from '@/services/providers/modal-provider'; -import LinksModal from './links-modal'; - interface Props { extended?: boolean; } const Links: FC<Props> = ({ extended }) => { + const [isExpanded, setIsExpanded] = useState(false); const [active, setActive] = useState<API.External['type']>('general'); const params = useParams(); const { openModal } = useModalContext(); @@ -37,32 +37,22 @@ const Links: FC<Props> = ({ extended }) => { const watchLinksData = anime.external.filter((l) => l.type === 'watch'); const generalLinksData = anime.external.filter((l) => l.type === 'general'); - const filteredWatchLinksData = watchLinksData.slice(0, 3); - const filteredGeneralLinksData = generalLinksData.slice(0, 3); - - const linksData = - active === 'general' - ? filteredGeneralLinksData - : filteredWatchLinksData; + const linksData = active === 'general' ? generalLinksData : watchLinksData; - const handleOpenLinksModal = () => { - openModal({ - type: 'sheet', - title: 'Посилання', - side: 'right', - content: <LinksModal />, - }); + const handleChangeActive = (value: API.External['type']) => { + if (value) { + setActive(value); + setIsExpanded(false); + } }; return ( <Block> - <Header title="Посилання" onClick={handleOpenLinksModal}> + <Header title="Посилання"> <ToggleGroup type="single" value={active} - onValueChange={(value: API.External['type']) => - value && setActive(value) - } + onValueChange={handleChangeActive} variant="outline" size="badge" > @@ -82,21 +72,25 @@ const Links: FC<Props> = ({ extended }) => { )} </ToggleGroup> </Header> - <div className="flex flex-col gap-6"> - {linksData.map((link) => ( - <HorizontalCard - key={link.url} - title={link.text} - description={link.url} - descriptionHref={link.url} - href={link.url} - imageRatio={1} - imageContainerClassName="w-10" - descriptionClassName="break-all" - image={<P>{link.text[0]}</P>} - /> - ))} - </div> + <TextExpand + expanded={isExpanded} + setExpanded={setIsExpanded} + className="max-h-40" + > + <div className="flex flex-col gap-4"> + {linksData.map((link) => ( + <HorizontalCard + key={link.url} + title={link.text} + href={link.url} + imageRatio={1} + imageContainerClassName="w-8" + descriptionClassName="break-all" + image={<P>{link.text[0]}</P>} + /> + ))} + </div> + </TextExpand> </Block> ); }; diff --git a/features/anime/anime-view/media/ost.tsx b/features/anime/anime-view/media/ost.tsx index 47582a0f..fe48de6c 100644 --- a/features/anime/anime-view/media/ost.tsx +++ b/features/anime/anime-view/media/ost.tsx @@ -30,7 +30,7 @@ const Ost: FC<Props> = ({ extended, ost }) => { description={ OST[ost.ost_type].title_ua || OST[ost.ost_type].title_en } - poster={ + image={ <IcBaselineLibraryMusic className="text-4xl text-muted-foreground" /> } /> diff --git a/features/anime/anime-view/media/video.tsx b/features/anime/anime-view/media/video.tsx index 2c131a04..83f1b08e 100644 --- a/features/anime/anime-view/media/video.tsx +++ b/features/anime/anime-view/media/video.tsx @@ -45,7 +45,7 @@ const Video: FC<Props> = ({ extended, videos }) => { key={video.url} href={video.url || '#'} title={video.title} - poster={thumb} + image={thumb} containerRatio={1.7} description={ VIDEO[video.video_type].title_ua || diff --git a/features/anime/anime-view/watch-stats/score.tsx b/features/anime/anime-view/watch-stats/score.tsx index a40a5a97..bad3539e 100644 --- a/features/anime/anime-view/watch-stats/score.tsx +++ b/features/anime/anime-view/watch-stats/score.tsx @@ -30,7 +30,7 @@ const Score = () => { const stats = Object.keys(data.stats) .reverse() - .reduce((acc: Hikka.WatchStat[], stat) => { + .reduce((acc: Hikka.ListStat[], stat) => { if ( stat.includes('score') && data.stats[stat as API.StatType] > 0 diff --git a/features/anime/anime-view/watch-stats/stats.tsx b/features/anime/anime-view/watch-stats/stats.tsx index bae43a85..2ebdc811 100644 --- a/features/anime/anime-view/watch-stats/stats.tsx +++ b/features/anime/anime-view/watch-stats/stats.tsx @@ -11,7 +11,7 @@ import { } from '@/components/ui/tooltip'; interface Props { - stats: Hikka.WatchStat[]; + stats: Hikka.ListStat[]; } const Stats: FC<Props> = ({ stats }) => { diff --git a/features/anime/anime-view/watch-stats/watchlist.tsx b/features/anime/anime-view/watch-stats/watchlist.tsx index 3f2a18c7..00e64599 100644 --- a/features/anime/anime-view/watch-stats/watchlist.tsx +++ b/features/anime/anime-view/watch-stats/watchlist.tsx @@ -24,7 +24,7 @@ const Watchlist = () => { data.stats.watching; const stats = Object.keys(data.stats).reduce( - (acc: Hikka.WatchStat[], stat) => { + (acc: Hikka.ListStat[], stat) => { if (!stat.includes('score')) { const status = WATCH_STATUS[stat as API.WatchStatus]; const percentage = diff --git a/features/characters/character-view/cover.component.tsx b/features/characters/character-view/cover.component.tsx index 24827a3f..86537498 100644 --- a/features/characters/character-view/cover.component.tsx +++ b/features/characters/character-view/cover.component.tsx @@ -18,7 +18,7 @@ const Cover = () => { return ( <div className="flex items-center px-16 md:px-48 lg:px-0"> - <ContentCard poster={character.image}> + <ContentCard image={character.image}> <div className="absolute bottom-2 right-2 z-[1]"> <FavoriteButton slug={character.slug} diff --git a/features/characters/character-view/manga.component.tsx b/features/characters/character-view/manga.component.tsx new file mode 100644 index 00000000..988c5a43 --- /dev/null +++ b/features/characters/character-view/manga.component.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import LoadMoreButton from '@/components/load-more-button'; +import MangaCard from '@/components/manga-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useCharacterManga from '@/services/hooks/characters/use-character-manga'; + +interface Props { + extended?: boolean; +} + +const Manga: FC<Props> = ({ extended }) => { + const params = useParams(); + const { list, fetchNextPage, hasNextPage, isFetchingNextPage, ref } = + useCharacterManga({ slug: String(params.slug) }); + + if (!list || list.length === 0) { + return null; + } + + return ( + <Block> + <Header + title={'Манґа'} + href={!extended ? params.slug + '/manga' : undefined} + /> + <Stack + size={5} + extendedSize={5} + className="grid-min-10" + extended={extended} + > + {(extended ? list : list.slice(0, 5)).map((ch) => ( + <MangaCard key={ch.manga.slug} manga={ch.manga} /> + ))} + </Stack> + {extended && hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + )} + </Block> + ); +}; + +export default Manga; diff --git a/features/characters/character-view/novel.component.tsx b/features/characters/character-view/novel.component.tsx new file mode 100644 index 00000000..ebdf3545 --- /dev/null +++ b/features/characters/character-view/novel.component.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import LoadMoreButton from '@/components/load-more-button'; +import NovelCard from '@/components/novel-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useCharacterNovel from '@/services/hooks/characters/use-character-novel'; + +interface Props { + extended?: boolean; +} + +const Novel: FC<Props> = ({ extended }) => { + const params = useParams(); + const { list, fetchNextPage, hasNextPage, isFetchingNextPage, ref } = + useCharacterNovel({ slug: String(params.slug) }); + + if (!list || list.length === 0) { + return null; + } + + return ( + <Block> + <Header + title={'Ранобе'} + href={!extended ? params.slug + '/novel' : undefined} + /> + <Stack + size={5} + extendedSize={5} + className="grid-min-10" + extended={extended} + > + {(extended ? list : list.slice(0, 5)).map((ch) => ( + <NovelCard key={ch.novel.slug} novel={ch.novel} /> + ))} + </Stack> + {extended && hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + )} + </Block> + ); +}; + +export default Novel; diff --git a/features/collections/collection-edit/anilist-collection.component.tsx b/features/collections/collection-edit/anilist-collection.component.tsx deleted file mode 100644 index ff63bb40..00000000 --- a/features/collections/collection-edit/anilist-collection.component.tsx +++ /dev/null @@ -1,179 +0,0 @@ -'use client'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation } from '@tanstack/react-query'; -import { useSnackbar } from 'notistack'; -import * as React from 'react'; -import { Dispatch, FC, SetStateAction, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import MaterialSymbolsCheckSmallRounded from '~icons/material-symbols/check-small-rounded'; - -import FormInput from '@/components/form/form-input'; -import { Button } from '@/components/ui/button'; -import { Form } from '@/components/ui/form'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectList, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; - -import getAnimeFromMAL from '@/services/api/integrations/mal/getAnimeFromMAL'; -import importAnilistWatch from '@/services/api/settings/importAnilistWatch'; -import { State } from '@/services/providers/collection-provider'; -import { useModalContext } from '@/services/providers/modal-provider'; -import { z } from '@/utils/zod'; - -interface Props { - setCollectionState: Dispatch<SetStateAction<State>>; -} - -const formSchema = z.object({ - username: z.string(), -}); - -const AnilistCollection: FC<Props> = ({ setCollectionState }) => { - const { closeModal } = useModalContext(); - const [selectedList, setSelectedList] = useState<string | undefined>( - undefined, - ); - const [watchList, setWatchList] = useState<Record<string, any>[]>([]); - const { enqueueSnackbar } = useSnackbar(); - const [aniListLoading, setAniListLoading] = useState(false); - - const form = useForm<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), - }); - - const mutation = useMutation({ - mutationFn: importAnilistWatch, - onSuccess: (data) => { - data.length > 0 && setWatchList(data); - }, - }); - - const getListNames = () => { - return watchList.reduce((acc: string[], val) => { - if (acc.includes(val.my_status)) return acc; - - acc.push(val.my_status); - return acc; - }, []); - }; - - const getWatchlist = async () => { - setAniListLoading(true); - try { - const res = await getAnimeFromMAL({ - params: { - mal_ids: watchList - .filter((item) => item.my_status === selectedList) - .map((item) => item.series_animedb_id), - }, - }); - - setCollectionState!((prev) => ({ - ...prev, - title: selectedList!, - groups: [ - { - id: selectedList!, - title: null, - isGroup: false, - items: res.map((anime) => ({ - id: anime.slug, - content: anime, - })), - }, - ], - })); - - closeModal(); - } catch (e) { - enqueueSnackbar('Не вдалось завантажити список аніме зі списку', { - variant: 'error', - }); - } - setAniListLoading(false); - }; - - const lists = getListNames().map((list: string) => ({ - value: list, - label: list, - })); - - return ( - <div className="flex w-full flex-col gap-6"> - <Form {...form}> - <form - className="flex items-end gap-2" - onSubmit={form.handleSubmit((data) => - mutation.mutate({ ...data, isCustomList: true }), - )} - > - <FormInput - label="Ім’я користувача AniList" - type="text" - name="username" - className="flex-1" - placeholder="Введіть імʼя користувача" - /> - <Button - size="icon" - type="submit" - variant="secondary" - disabled={mutation.isPending} - > - {mutation.isPending ? ( - <span className="loading loading-spinner"></span> - ) : ( - <MaterialSymbolsCheckSmallRounded className="text-2xl" /> - )} - </Button> - </form> - </Form> - - <div className="flex w-full flex-col gap-2"> - <Label>Список</Label> - <div className="flex gap-2"> - <Select - disabled={lists.length === 0 || aniListLoading} - value={selectedList ? [selectedList] : undefined} - onValueChange={(value) => setSelectedList(value[0])} - > - <SelectTrigger> - <SelectValue placeholder="Виберіть список..." /> - </SelectTrigger> - <SelectContent> - <SelectList> - <SelectGroup> - {lists.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - > - {option.label} - </SelectItem> - ))} - </SelectGroup> - </SelectList> - </SelectContent> - </Select> - </div> - </div> - - <Button - disabled={!selectedList || aniListLoading} - onClick={getWatchlist} - > - Імпортувати - </Button> - </div> - ); -}; - -export default AnilistCollection; diff --git a/features/collections/collection-edit/collection-grid/collection-grid.component.tsx b/features/collections/collection-edit/collection-grid/collection-grid.component.tsx index 2584bef7..5811406a 100644 --- a/features/collections/collection-edit/collection-grid/collection-grid.component.tsx +++ b/features/collections/collection-edit/collection-grid/collection-grid.component.tsx @@ -83,7 +83,7 @@ const CollectionGrid: FC<Props> = ({ group }) => { } }; - const handleAddItem = (content: API.MainContent) => { + const handleAddItem = (content: API.MainContent & { title?: string }) => { if (JSON.stringify(groups).includes(content.slug)) { return; } @@ -156,12 +156,16 @@ const CollectionGrid: FC<Props> = ({ group }) => { <SearchModal content_type={content_type} onClick={(value) => - handleAddItem(value as API.MainContent) + handleAddItem( + value as API.MainContent & { + title?: string; + }, + ) } type="button" > <ContentCard - poster={ + image={ <MaterialSymbolsAddRounded className="text-4xl text-muted-foreground" /> } /> diff --git a/features/collections/collection-edit/collection-grid/sortable-card.tsx b/features/collections/collection-edit/collection-grid/sortable-card.tsx index 3b403fcf..4c33cfad 100644 --- a/features/collections/collection-edit/collection-grid/sortable-card.tsx +++ b/features/collections/collection-edit/collection-grid/sortable-card.tsx @@ -1,6 +1,6 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import React, { FC, memo, useMemo } from 'react'; +import { FC, memo, useMemo } from 'react'; import MaterialSymbolsDeleteForever from '~icons/material-symbols/delete-forever'; import MaterialSymbolsDragIndicator from '~icons/material-symbols/drag-indicator'; @@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'; interface Props { id: string; - content: API.Anime | API.Character | API.Person; + content: API.MainContent & { title?: string }; onRemove: () => void; } @@ -22,17 +22,11 @@ const SortableCard: FC<Props> = ({ id, content, onRemove }) => { transition, }; - const poster = 'poster' in content ? content.poster : content.image; - const title = - 'title_ua' in content - ? content.title_ua || content.title_en || content.title_ja - : content.name_ua || content.name_en; - return ( <div ref={setNodeRef} style={style} {...attributes}> {useMemo( () => ( - <ContentCard poster={poster} title={title}> + <ContentCard image={content.image} title={content.title}> <div className="absolute bottom-0 left-0 w-full"> <div className="absolute bottom-2 right-2 z-[1] flex gap-2"> <Button diff --git a/features/collections/collection-edit/collection-settings/collection-settings.component.tsx b/features/collections/collection-edit/collection-settings/collection-settings.component.tsx index 8ff208fa..c2e56b2f 100644 --- a/features/collections/collection-edit/collection-settings/collection-settings.component.tsx +++ b/features/collections/collection-edit/collection-settings/collection-settings.component.tsx @@ -2,7 +2,6 @@ import { useParams } from 'next/navigation'; import { FC } from 'react'; -import SimpleIconsAnilist from '~icons/simple-icons/anilist'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -20,7 +19,6 @@ import { } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; -import AnilistCollection from '@/features/collections/collection-edit/anilist-collection.component'; import GroupInputs from '@/features/collections/collection-edit/collection-settings/group-inputs'; import useCreateCollection from '@/services/hooks/collections/use-create-collection'; @@ -111,15 +109,6 @@ const CollectionSettings: FC<Props> = ({ mode = 'create' }) => { }); }; - const handleImportAnilistModal = () => { - openModal({ - content: ( - <AnilistCollection setCollectionState={setCollectionState!} /> - ), - title: 'Імпорт з AniList', - }); - }; - const handleChangeContentType = (value: string[]) => setCollectionState!((state) => ({ ...state, @@ -305,13 +294,6 @@ const CollectionSettings: FC<Props> = ({ mode = 'create' }) => { )} Створити </Button> - <Button - size="icon" - variant="secondary" - onClick={handleImportAnilistModal} - > - <SimpleIconsAnilist /> - </Button> </div> )} </div> diff --git a/features/collections/collection-list/collection-item.tsx b/features/collections/collection-list/collection-item.tsx index 7a0d1ea0..0d1ce7ae 100644 --- a/features/collections/collection-list/collection-item.tsx +++ b/features/collections/collection-list/collection-item.tsx @@ -1,9 +1,11 @@ 'use client'; +import formatDistance from 'date-fns/formatDistance'; import Link from 'next/link'; import { FC, memo } from 'react'; import BxBxsUpvote from '~icons/bx/bxs-upvote'; import IconamoonCommentFill from '~icons/iconamoon/comment-fill'; +import MaterialSymbolsDriveFileRenameOutlineRounded from '~icons/material-symbols/drive-file-rename-outline-rounded'; import MaterialSymbolsGridViewRounded from '~icons/material-symbols/grid-view-rounded'; import MaterialSymbolsMoreHoriz from '~icons/material-symbols/more-horiz'; @@ -18,17 +20,10 @@ import { CONTENT_TYPE_LINKS } from '@/utils/constants'; import { cn } from '@/utils/utils'; interface Props { - collection: API.Collection<API.MainContent>; + collection: API.Collection<API.MainContent & { title?: string }>; } const CollectionItem: FC<Props> = ({ collection }) => { - const poster = (content: API.MainContent) => - 'poster' in content ? content.poster : content.image; - const title = (content: API.MainContent) => - 'title_ua' in content - ? content.title_ua || content.title_en || content.title_ja - : content.name_ua || content.name_en; - return ( <div className="flex flex-col gap-4"> <div className={cn('flex gap-2')}> @@ -77,6 +72,18 @@ const CollectionItem: FC<Props> = ({ collection }) => { <BxBxsUpvote /> <Small>{collection.vote_score}</Small> </div> + <div className="flex gap-1"> + <MaterialSymbolsDriveFileRenameOutlineRounded /> + <Small> + {formatDistance( + collection.updated * 1000, + Date.now(), + { + addSuffix: true, + }, + )} + </Small> + </div> </div> </div> </div> @@ -100,8 +107,8 @@ const CollectionItem: FC<Props> = ({ collection }) => { className={cn(collection.spoiler && 'spoiler-blur-md')} href={`${CONTENT_TYPE_LINKS[item.content_type]}/${item.content.slug}`} key={item.content.slug} - poster={poster(item.content)} - title={title(item.content)} + image={item.content.image} + title={item.content.title} slug={item.content.slug} content_type={item.content_type} watch={ @@ -114,7 +121,7 @@ const CollectionItem: FC<Props> = ({ collection }) => { ))} <ContentCard href={`/collections/${collection.reference}`} - poster={ + image={ <MaterialSymbolsMoreHoriz className="text-4xl text-muted-foreground" /> } /> diff --git a/features/collections/collection-view/collection-groups/collection-grid.tsx b/features/collections/collection-view/collection-groups/collection-grid.tsx index a4601635..2d0cc15d 100644 --- a/features/collections/collection-view/collection-groups/collection-grid.tsx +++ b/features/collections/collection-view/collection-groups/collection-grid.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { FC, memo } from 'react'; +import { FC, memo } from 'react'; import ContentCard from '@/components/content-card/content-card'; import Header from '@/components/ui/header'; @@ -25,13 +25,6 @@ const CollectionGrid: FC<Props> = ({ group }) => { const items = groups.find((g) => g.id === group.id)?.items || []; - const poster = (content: API.MainContent) => - 'poster' in content ? content.poster : content.image; - const title = (content: API.MainContent) => - 'title_ua' in content - ? content.title_ua || content.title_en || content.title_ja - : content.name_ua || content.name_en; - return ( <div className="flex flex-col gap-4"> {group.isGroup && ( @@ -51,8 +44,8 @@ const CollectionGrid: FC<Props> = ({ group }) => { content_type={content_type} href={`${CONTENT_TYPE_LINKS[content_type]}/${item.content.slug}`} key={item.id} - poster={poster(item.content)} - title={title(item.content)} + image={item.content.image} + title={item.content.title} watch={ 'watch' in item.content && item.content.watch.length > 0 diff --git a/features/collections/collection-view/collection-info/collection-info.component.tsx b/features/collections/collection-view/collection-info/collection-info.component.tsx index 0b79cca3..56b8c4ea 100644 --- a/features/collections/collection-view/collection-info/collection-info.component.tsx +++ b/features/collections/collection-view/collection-info/collection-info.component.tsx @@ -1,5 +1,6 @@ 'use client'; +import formatDistance from 'date-fns/formatDistance'; import Link from 'next/link'; import { useParams } from 'next/navigation'; @@ -46,6 +47,7 @@ const CollectionInfo = () => { <div className="flex size-full flex-col gap-4"> <Card className="w-full gap-6"> <CollectionAuthor /> + {tags.length > 0 && ( <div className="flex items-center gap-2"> {tags.map((tag) => ( @@ -55,6 +57,34 @@ const CollectionInfo = () => { ))} </div> )} + <div className="flex items-center justify-between gap-4"> + <Label className="text-muted-foreground"> + Створено + </Label> + <Label className="text-muted-foreground"> + {formatDistance( + collection.created * 1000, + Date.now(), + { + addSuffix: true, + }, + )} + </Label> + </div> + <div className="flex items-center justify-between gap-4"> + <Label className="text-muted-foreground"> + Оновлено + </Label> + <Label className="text-muted-foreground"> + {formatDistance( + collection.updated * 1000, + Date.now(), + { + addSuffix: true, + }, + )} + </Label> + </div> <div className="flex items-center justify-between gap-4"> <Label htmlFor="nsfw" className="text-muted-foreground"> Контент +18 diff --git a/features/comments/comment-content.component.tsx b/features/comments/comment-content.component.tsx index bde0fb07..c6acbc14 100644 --- a/features/comments/comment-content.component.tsx +++ b/features/comments/comment-content.component.tsx @@ -23,11 +23,11 @@ const CommentContent: FC<Props> = ({ slug, content_type }) => { return ( <Block> <div className="hidden w-full items-center gap-4 px-16 md:px-48 lg:flex lg:px-0"> - <ContentCard href={link} poster={data?.image} /> + <ContentCard href={link} image={data?.image} /> </div> <div className="flex w-full gap-4 lg:hidden"> <div className="w-12"> - <ContentCard href={link} poster={data?.image} /> + <ContentCard href={link} image={data?.image} /> </div> <Link href={link}>{data?.title}</Link> </div> diff --git a/features/comments/comment-list/comment-list.component.tsx b/features/comments/comment-list.component.tsx similarity index 95% rename from features/comments/comment-list/comment-list.component.tsx rename to features/comments/comment-list.component.tsx index 68105c69..b4b09d87 100644 --- a/features/comments/comment-list/comment-list.component.tsx +++ b/features/comments/comment-list.component.tsx @@ -3,15 +3,14 @@ import Link from 'next/link'; import { FC } from 'react'; +import CommentInput from '@/components/comments/comment-input'; +import Comments from '@/components/comments/comments'; import LoadMoreButton from '@/components/load-more-button'; import Block from '@/components/ui/block'; import { Button } from '@/components/ui/button'; import Header from '@/components/ui/header'; import NotFound from '@/components/ui/not-found'; -import CommentInput from '@/features/comments/comment-list/comment-input'; -import Comments from '@/features/comments/comment-list/comments'; - import useSession from '@/services/hooks/auth/use-session'; import useCommentThread from '@/services/hooks/comments/use-comment-thread'; import useComments from '@/services/hooks/comments/use-comments'; diff --git a/features/comments/latest-comments.component.tsx b/features/comments/latest-comments.component.tsx index dcff9854..f27e0bc6 100644 --- a/features/comments/latest-comments.component.tsx +++ b/features/comments/latest-comments.component.tsx @@ -2,13 +2,12 @@ import { FC } from 'react'; -import ContentCard from '@/components/content-card/content-card'; +import GlobalComment from '@/components/comments/global-comment'; import LoadMoreButton from '@/components/load-more-button'; import { Badge } from '@/components/ui/badge'; import Block from '@/components/ui/block'; import Card from '@/components/ui/card'; import Header from '@/components/ui/header'; -import HorizontalCard from '@/components/ui/horizontal-card'; import NotFound from '@/components/ui/not-found'; import Stack from '@/components/ui/stack'; @@ -27,10 +26,10 @@ const Comments: FC<Props> = ({ className }) => { <Block className={cn(className)}> <Header title="Останні коментарі" /> <Stack - size={3} extended extendedSize={3} - className="grid-cols-1 md:grid-cols-1" + size={3} + className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3" > {list?.map((item, index) => ( <Card key={item.reference}> @@ -40,22 +39,10 @@ const Comments: FC<Props> = ({ className }) => { > #{index + 1} </Badge> - <HorizontalCard - image={item.author.avatar} - imageRatio={1} - description={item.text} - descriptionHref={`/comments/${item.content_type}/${item.slug}`} - key={item.created} - title={item.author.username} - href={`/u/${item.author.username}`} - createdAt={item.created} - > - <ContentCard - className="w-10" - poster={item.image} - href={`/comments/${item.content_type}/${item.slug}`} - /> - </HorizontalCard> + <GlobalComment + href={`/comments/${item.content_type}/${item.preview.slug}`} + comment={item} + /> </Card> ))} {list?.length === 0 && ( diff --git a/features/comments/useContent.ts b/features/comments/useContent.ts index 3aecb14e..1dbc3db9 100644 --- a/features/comments/useContent.ts +++ b/features/comments/useContent.ts @@ -4,8 +4,11 @@ import getAnimeInfo from '@/services/api/anime/getAnimeInfo'; import getCharacterInfo from '@/services/api/characters/getCharacterInfo'; import getCollection from '@/services/api/collections/getCollection'; import getEdit from '@/services/api/edit/getEdit'; +import getMangaInfo from '@/services/api/manga/getMangaInfo'; +import getNovelInfo from '@/services/api/novel/getNovelInfo'; import getPersonInfo from '@/services/api/people/getPersonInfo'; import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; interface Props { content_type: API.ContentType; @@ -22,6 +25,10 @@ export const getContent = ({ content_type, slug }: Props) => { switch (content_type) { case 'anime': return getAnimeInfo({ params: { slug } }); + case 'manga': + return getMangaInfo({ params: { slug } }); + case 'novel': + return getNovelInfo({ params: { slug } }); case 'character': return getCharacterInfo({ params: { slug } }); case 'person': @@ -35,12 +42,24 @@ export const getContent = ({ content_type, slug }: Props) => { } }; -const useContent = ({ slug, content_type }: Props) => { +export const paramsBuilder = (props: Props): Props => ({ + slug: props.slug, + content_type: props.content_type, +}); + +export const key = (props: Props) => [ + 'content', + props.content_type, + props.slug, +]; + +const useContent = (props: Props) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); const query = useQuery({ - queryKey: ['content', content_type, slug], - queryFn: async () => getContent({ content_type, slug }), + queryKey: key(params), + queryFn: async () => getContent(params), select: (data) => { let content: Response | undefined; @@ -52,8 +71,23 @@ const useContent = ({ slug, content_type }: Props) => { data.title_ua || data.title_en || data.title_ja, - image: data.poster, - content_type, + image: data.image, + content_type: params.content_type, + }; + } + + if (data.data_type === 'manga' || data.data_type === 'novel') { + content = { + title: + data[ + titleLanguage === 'title_ja' + ? 'title_original' + : titleLanguage! + ] || + data.title_ua || + data.title_en, + image: data.image, + content_type: params.content_type, }; } @@ -61,7 +95,7 @@ const useContent = ({ slug, content_type }: Props) => { content = { title: data.name_ua || data.name_en || data.name_ja, image: data.image, - content_type, + content_type: params.content_type, }; } @@ -69,7 +103,7 @@ const useContent = ({ slug, content_type }: Props) => { content = { title: data.name_ua || data.name_en || data.name_native, image: data.image, - content_type, + content_type: params.content_type, }; } @@ -78,9 +112,9 @@ const useContent = ({ slug, content_type }: Props) => { title: data.title, image: data.collection[0].content.data_type === 'anime' - ? data.collection[0].content.poster + ? data.collection[0].content.image : data.collection[0].content.image, - content_type, + content_type: params.content_type, }; } } else { @@ -88,9 +122,9 @@ const useContent = ({ slug, content_type }: Props) => { title: `Правка #${data.edit_id}`, image: data.content.data_type === 'anime' - ? data.content.poster + ? data.content.image : data.content.image, - content_type, + content_type: params.content_type, }; } @@ -101,4 +135,14 @@ const useContent = ({ slug, content_type }: Props) => { return query; }; +export const prefetchContent = (props: Props) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: async () => getContent(params), + }); +}; + export default useContent; diff --git a/features/common/modal-manager.component.tsx b/features/common/modal-manager.component.tsx index 8a95837a..a639c18d 100644 --- a/features/common/modal-manager.component.tsx +++ b/features/common/modal-manager.component.tsx @@ -17,8 +17,7 @@ const ModalManager = () => { case 'passwordConfirm': openModal({ content: <AuthModal type="passwordConfirm" />, - className: 'max-w-3xl', - containerClassName: 'p-0', + className: 'max-w-3xl p-0', forceModal: true, }); break; diff --git a/features/common/navbar/navbar.component.tsx b/features/common/navbar/navbar.component.tsx index fe63ed45..b7bfaf01 100644 --- a/features/common/navbar/navbar.component.tsx +++ b/features/common/navbar/navbar.component.tsx @@ -74,9 +74,8 @@ const Navbar = () => { onClick={() => openModal({ content: <AuthModal type="login" />, - className: 'max-w-3xl', + className: 'max-w-3xl p-0', forceModal: true, - containerClassName: 'p-0', }) } > @@ -88,9 +87,8 @@ const Navbar = () => { onClick={() => openModal({ content: <AuthModal type="signup" />, - className: 'max-w-3xl', + className: 'max-w-3xl p-0', forceModal: true, - containerClassName: 'p-0', }) } > diff --git a/features/common/navbar/notifications-menu/notification-item.tsx b/features/common/navbar/notifications-menu/notification-item.tsx index 2e9f5e94..b33f5ac1 100644 --- a/features/common/navbar/notifications-menu/notification-item.tsx +++ b/features/common/navbar/notifications-menu/notification-item.tsx @@ -47,7 +47,7 @@ const NotificationItem: FC<Props> = ({ data }) => { ) } > - {data.poster && data.poster} + {data.image && data.image} </HorizontalCard> </Link> </DropdownMenuItem> diff --git a/features/common/navbar/profile-menu.tsx b/features/common/navbar/profile-menu.tsx index bd223809..ed29b708 100644 --- a/features/common/navbar/profile-menu.tsx +++ b/features/common/navbar/profile-menu.tsx @@ -1,10 +1,12 @@ 'use client'; import Link from 'next/link'; -import MaterialSymbolsEventList from '~icons/material-symbols/event-list'; import MaterialSymbolsFavoriteRounded from '~icons/material-symbols/favorite-rounded'; import MaterialSymbolsLogoutRounded from '~icons/material-symbols/logout-rounded'; +import MaterialSymbolsMenuBookRounded from '~icons/material-symbols/menu-book-rounded'; +import MaterialSymbolsPalette from '~icons/material-symbols/palette'; import MaterialSymbolsPerson from '~icons/material-symbols/person'; +import MaterialSymbolsPlayArrowRounded from '~icons/material-symbols/play-arrow-rounded'; import MaterialSymbolsSettingsOutline from '~icons/material-symbols/settings-outline'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; @@ -72,11 +74,41 @@ const ProfileMenu = () => { </Link> </DropdownMenuItem> <DropdownMenuItem asChild> - <Link href={'/u/' + loggedUser.username + '/list'}> - <MaterialSymbolsEventList className="mr-2 size-4" /> + <Link + href={ + '/u/' + + loggedUser.username + + '/list/anime?status=planned&sort=watch_score' + } + > + <MaterialSymbolsPlayArrowRounded className="mr-2 size-4" /> Список аніме </Link> </DropdownMenuItem> + <DropdownMenuItem asChild> + <Link + href={ + '/u/' + + loggedUser.username + + '/list/manga?status=planned&sort=read_score' + } + > + <MaterialSymbolsPalette className="mr-2 size-4" /> + Список манґи + </Link> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <Link + href={ + '/u/' + + loggedUser.username + + '/list/novel?status=planned&sort=read_score' + } + > + <MaterialSymbolsMenuBookRounded className="mr-2 size-4" /> + Список ранобе + </Link> + </DropdownMenuItem> <DropdownMenuItem asChild> <Link href={'/u/' + loggedUser.username + '/favorites'}> <MaterialSymbolsFavoriteRounded className="mr-2 size-4" /> diff --git a/features/common/rightholder.component.tsx b/features/common/rightholder.component.tsx index df761bbf..361da6fc 100644 --- a/features/common/rightholder.component.tsx +++ b/features/common/rightholder.component.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; const Rightholder = () => { return ( - <div className="markdown"> + <div className="markdown px-6 py-4"> <p> Якщо Ви помітили матеріал на нашому сайті, що порушує Ваші авторські права, або іншим чином дотичне до Вас, будь ласка, diff --git a/features/common/session-manager.component.tsx b/features/common/session-manager.component.tsx index 9ce0567c..495328c2 100644 --- a/features/common/session-manager.component.tsx +++ b/features/common/session-manager.component.tsx @@ -15,7 +15,7 @@ const SessionManager = async ({ children }: Props) => { try { auth && (await queryClient.fetchQuery({ - queryKey: ['loggedUser'], + queryKey: ['logged-user'], queryFn: ({ meta }) => getLoggedUserInfo({}), })); } catch (e) { diff --git a/features/edit/edit-content-list.component.tsx b/features/edit/edit-content-list.component.tsx index 1a73a996..3e1cf3b7 100644 --- a/features/edit/edit-content-list.component.tsx +++ b/features/edit/edit-content-list.component.tsx @@ -109,7 +109,7 @@ const ContentList: FC<Props> = ({ extended }) => { } slug={anime.slug} href={`/anime/${anime.slug}`} - poster={anime.poster} + image={anime.image} title={anime.title} /> ))} diff --git a/features/edit/edit-content/details.tsx b/features/edit/edit-content/details.tsx index 56e49bd8..cf4b197c 100644 --- a/features/edit/edit-content/details.tsx +++ b/features/edit/edit-content/details.tsx @@ -1,22 +1,32 @@ -import * as React from 'react'; import { FC } from 'react'; import P from '@/components/typography/p'; import { Label } from '@/components/ui/label'; +import { getTitle } from '@/utils/title-adapter'; + interface Props { content: API.MainContent; } const Details: FC<Props> = ({ content }) => { - const title_ua = 'title_ua' in content ? content.title_ua : content.name_ua; - const title_en = 'title_en' in content ? content.title_en : content.name_en; - const title_ja = - 'title_ja' in content - ? content.title_ja - : 'name_ja' in content - ? content.name_ja - : content.name_native; + const title_ua = getTitle({ + data: content, + titleLanguage: content.data_type === 'anime' ? 'title_ua' : 'name_ua', + }); + const title_en = getTitle({ + data: content, + titleLanguage: content.data_type === 'anime' ? 'title_en' : 'name_en', + }); + const title_ja = getTitle({ + data: content, + titleLanguage: + content.data_type === 'anime' + ? 'title_ja' + : content.data_type === 'manga' || content.data_type === 'novel' + ? 'title_original' + : 'name_ja', + }); return ( <div className="flex flex-col gap-4 rounded-md border border-secondary/60 bg-secondary/30 p-4"> diff --git a/features/edit/edit-content/edit-content.component.tsx b/features/edit/edit-content/edit-content.component.tsx index ac9c9d6b..f7b87739 100644 --- a/features/edit/edit-content/edit-content.component.tsx +++ b/features/edit/edit-content/edit-content.component.tsx @@ -11,6 +11,7 @@ import Details from '@/features/edit/edit-content/details'; import General from '@/features/edit/edit-content/general'; import { CONTENT_TYPE_LINKS } from '@/utils/constants'; +import { getTitle } from '@/utils/title-adapter'; interface Props { slug: string; @@ -27,12 +28,8 @@ const EditContent: FC<Props> = ({ slug, content_type, content }) => { const link = `${CONTENT_TYPE_LINKS[content_type]}/${slug}`; - const poster = - content.data_type === 'anime' ? content.poster : content.image; - const title = - content.data_type === 'anime' - ? content.title! - : content.name_ua || content.name_en; + const image = content.data_type === 'anime' ? content.image : content.image; + const title = getTitle({ data: content, titleLanguage: 'title_ua' }); return ( <Block> @@ -53,7 +50,7 @@ const EditContent: FC<Props> = ({ slug, content_type, content }) => { </Button> </Header> {type === 'general' && ( - <General href={link} poster={poster} title={title} /> + <General href={link} image={image} title={title} /> )} {type === 'details' && <Details content={content} />} </Block> diff --git a/features/edit/edit-content/general.tsx b/features/edit/edit-content/general.tsx index eae42e44..9eaa270b 100644 --- a/features/edit/edit-content/general.tsx +++ b/features/edit/edit-content/general.tsx @@ -1,24 +1,23 @@ import Link from 'next/link'; -import * as React from 'react'; import { FC } from 'react'; import ContentCard from '@/components/content-card/content-card'; interface Props { href: string; - poster: string; + image: string; title: string; } -const General: FC<Props> = ({ href, title, poster }) => { +const General: FC<Props> = ({ href, title, image }) => { return ( <> <div className="hidden w-full items-center gap-4 px-16 md:px-48 lg:flex lg:px-0"> - <ContentCard href={href} poster={poster} /> + <ContentCard href={href} image={image} /> </div> <div className="flex w-full gap-4 lg:hidden"> <div className="w-12"> - <ContentCard href={href} poster={poster} /> + <ContentCard href={href} image={image} /> </div> <Link href={href}>{title}</Link> </div> diff --git a/features/edit/edit-forms/edit-group.tsx b/features/edit/edit-forms/edit-group.tsx index a1b83ad4..f35e872e 100644 --- a/features/edit/edit-forms/edit-group.tsx +++ b/features/edit/edit-forms/edit-group.tsx @@ -1,8 +1,8 @@ 'use client'; -import { ChevronsUpDown } from 'lucide-react'; import * as React from 'react'; import { FC } from 'react'; +import LucideChevronsUpDown from '~icons/lucide/chevrons-up-down'; import H5 from '@/components/typography/h5'; import { Button } from '@/components/ui/button'; @@ -45,7 +45,7 @@ const EditGroup: FC<Props> = ({ title, params, mode }) => { size="sm" className="w-9 p-0" > - <ChevronsUpDown className="size-4" /> + <LucideChevronsUpDown className="size-4" /> <span className="sr-only">Toggle</span> </Button> </div> diff --git a/features/edit/edit-forms/params/markdown-param.tsx b/features/edit/edit-forms/params/markdown-param.tsx index 6a5e37ac..8e7526cc 100644 --- a/features/edit/edit-forms/params/markdown-param.tsx +++ b/features/edit/edit-forms/params/markdown-param.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { FC } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; +import PlateDiff from '@/components/markdown/plate-editor/plate-diff'; import PlateEditor from '@/components/markdown/plate-editor/plate-editor'; import MDViewer from '@/components/markdown/viewer/MD-viewer'; import { Button } from '@/components/ui/button'; @@ -56,9 +57,11 @@ const MarkdownParam: FC<Props> = ({ mode, param }) => { edit && edit.before![param.slug] && showDiff && ( - <MDViewer className="markdown rounded-md border border-secondary/60 bg-secondary/30 p-4 text-sm opacity-50 hover:opacity-100"> - {edit.before![param.slug]} - </MDViewer> + <PlateDiff + className="opacity-50 hover:opacity-100" + current={edit.after![param.slug]} + previous={edit.before![param.slug]} + /> )} </div> ); diff --git a/features/edit/edit-list/edit-list.component.tsx b/features/edit/edit-list/edit-list.component.tsx index 718e5a4f..03578877 100644 --- a/features/edit/edit-list/edit-list.component.tsx +++ b/features/edit/edit-list/edit-list.component.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useSearchParams } from 'next/navigation'; import { FC } from 'react'; import FiltersNotFound from '@/components/filters-not-found'; @@ -18,7 +19,25 @@ interface Props { } const EditList: FC<Props> = ({ page }) => { - const { data: edits, isLoading } = useEditList({ page: Number(page) }); + const searchParams = useSearchParams(); + + const content_type = + (searchParams.get('content_type') as API.ContentType) || undefined; + const order = searchParams.get('order') || 'desc'; + const sort = searchParams.get('sort') || 'edit_id'; + const edit_status = + (searchParams.get('edit_status') as API.EditStatus) || undefined; + const author = searchParams.get('author'); + const moderator = searchParams.get('moderator'); + + const { data: edits, isLoading } = useEditList({ + page: Number(page), + content_type, + sort: [`${sort}:${order}`], + status: edit_status, + author, + moderator, + }); if (isLoading) { return <EditSkeleton />; diff --git a/features/edit/edit-list/edit-row.tsx b/features/edit/edit-list/edit-row.tsx index 4349fb3c..72f6f162 100644 --- a/features/edit/edit-list/edit-row.tsx +++ b/features/edit/edit-list/edit-row.tsx @@ -18,6 +18,7 @@ import { EDIT_PARAMS, EDIT_STATUS, } from '@/utils/constants'; +import { getTitle } from '@/utils/title-adapter'; interface Props { edit: API.Edit; @@ -61,9 +62,10 @@ const EditRow: FC<Props> = ({ edit }) => { edit.content.slug }`} > - {edit.content.data_type === 'anime' - ? edit.content.title - : edit.content.name_ua || edit.content.name_en} + {getTitle({ + data: edit.content, + titleLanguage: 'title_ua', + })} </Link> </div> <Label className="text-xs text-muted-foreground"> diff --git a/features/edit/edit-rules-alert.component.tsx b/features/edit/edit-rules-alert.component.tsx index d6ee7fbd..c055685d 100644 --- a/features/edit/edit-rules-alert.component.tsx +++ b/features/edit/edit-rules-alert.component.tsx @@ -30,13 +30,10 @@ const EditRulesAlert = () => { onClick={() => openModal({ content: ( - <MDViewer className="overflow-y-scroll md:overflow-hidden"> + <MDViewer className="overflow-y-scroll px-6 py-4 md:overflow-hidden"> {rules} </MDViewer> ), - className: 'max-w-xl', - containerClassName: - 'overflow-scroll max-h-[90dvh]', title: 'Правила редагування', }) } diff --git a/features/filters/anime-filters.component.tsx b/features/filters/anime-filters.component.tsx index 6a973b99..a46dc7ec 100644 --- a/features/filters/anime-filters.component.tsx +++ b/features/filters/anime-filters.component.tsx @@ -1,368 +1,67 @@ 'use client'; -import clsx from 'clsx'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { FC, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { FC } from 'react'; import AntDesignClearOutlined from '~icons/ant-design/clear-outlined'; -import MaterialSymbolsSortRounded from '~icons/material-symbols/sort-rounded'; import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { - Select, - SelectContent, - SelectEmpty, - SelectGroup, - SelectItem, - SelectList, - SelectSearch, - SelectTrigger, - SelectValue, - renderSelectOptions, -} from '@/components/ui/select'; -import { Slider } from '@/components/ui/slider'; -import { Switch } from '@/components/ui/switch'; -import useAnimeGenres from '@/services/hooks/anime/use-anime-genres'; -import useCompanies from '@/services/hooks/companies/use-companies'; -import { - AGE_RATING, - MEDIA_TYPE, - RELEASE_STATUS, - SEASON, -} from '@/utils/constants'; -import createQueryString from '@/utils/create-query-string'; import { cn } from '@/utils/utils'; -import BadgeFilter from './badge-filter'; -import YearFilterInput from './year-filter-input'; - -const YEARS: [number, number] = [1965, new Date().getFullYear()]; -const DEFAULT_YEAR_START = YEARS[0].toString(); -const DEFAULT_YEAR_END = YEARS[1].toString(); - -enum RANGE { - MIN = 'min', - MAX = 'max', -} +import AgeRating from './prebuilt/age-rating'; +import Genre from './prebuilt/genre'; +import Localization from './prebuilt/localization'; +import MediaType from './prebuilt/media-type'; +import ReleaseStatus from './prebuilt/release-status'; +import Season from './prebuilt/season'; +import Sort from './prebuilt/sort'; +import Studio from './prebuilt/studio'; +import Year from './prebuilt/year'; interface Props { className?: string; - type: 'anime' | 'watchlist'; + content_type: API.ContentType; + sort_type: 'anime' | 'watch'; } -const SORT_ANIME = [ - { - label: 'Загальна оцінка', - value: 'score', - }, - { - label: 'Дата релізу', - value: 'start_date', - }, - { - label: 'Тип', - value: 'media_type', - }, -]; - -const SORT_WATCHLIST = [ - ...SORT_ANIME, - { - label: 'К-сть епізодів', - value: 'watch_episodes', - }, - { - label: 'Дата додавання', - value: 'watch_created', - }, - { - label: 'Власна оцінка', - value: 'watch_score', - }, -]; - -const AnimeFilters: FC<Props> = ({ className, type }) => { +const AnimeFilters: FC<Props> = ({ className, content_type, sort_type }) => { const router = useRouter(); const pathname = usePathname(); - const searchParams = useSearchParams()!; - - const types = searchParams.getAll('types'); - const statuses = searchParams.getAll('statuses'); - const seasons = searchParams.getAll('seasons'); - const ageRatings = searchParams.getAll('ratings'); - const years = searchParams.getAll('years'); - const genres = searchParams.getAll('genres'); - const studios = searchParams.getAll('studios'); - const lang = searchParams.get('only_translated'); - const order = searchParams.get('order'); - const sort = searchParams.get('sort') || 'score'; - - const sortOptions = type === 'anime' ? SORT_ANIME : SORT_WATCHLIST; - - const { data: genresList } = useAnimeGenres(); - - const [studioSearch, setStudioSearch] = useState<string>(); - const { data: studioList, isFetching: isStudioListFetching } = useCompanies( - { - type: 'studio', - query: studioSearch, - }, - ); - - const [selectingYears, setSelectingYears] = useState<string[]>( - years.length > 0 ? years : YEARS.map((y) => String(y)), - ); const clearFilters = () => { router.replace(`${pathname}`); - setSelectingYears(YEARS.map((y) => String(y))); + // setSelectingYears(YEARS.map((y) => String(y))); }; - const handleChangeParam = ( - name: string, - value: string | string[] | boolean, - ) => { - const query = createQueryString( - name, - value, - createQueryString( - 'page', - '1', - createQueryString( - 'iPage', - '1', - new URLSearchParams(searchParams), - ), - ), - ); - router.replace(`${pathname}?${query}`); - }; - - const handleStudioSearch = (keyword: string) => { - if (keyword.length < 3) { - setStudioSearch(undefined); - return; - } - - setStudioSearch(keyword); - }; - - useEffect(() => { - if (JSON.stringify(selectingYears) !== JSON.stringify(years)) { - setSelectingYears( - years.length > 0 ? years : YEARS.map((y) => String(y)), - ); - } - }, [searchParams]); - return ( <ScrollArea className={cn( - 'flex flex-col items-start gap-8', - 'border-t border-t-transparent', - 'transition', - 'h-full lg:max-h-[calc(100vh-6rem)]', + 'flex h-full flex-col lg:max-h-[calc(100vh-6rem)]', className, )} > - <div className="flex w-full flex-col items-start gap-8 py-4"> - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground">Сортування</Label> - <div className="flex gap-2"> - <Select - value={[sort]} - onValueChange={(value) => - handleChangeParam('sort', value[0]) - } - > - <SelectTrigger className="flex-1"> - <SelectValue placeholder="Виберіть сортування..." /> - </SelectTrigger> - <SelectContent> - <SelectList> - <SelectGroup> - {sortOptions.map((item) => ( - <SelectItem - key={item.value} - value={item.value} - > - {item.label} - </SelectItem> - ))} - </SelectGroup> - </SelectList> - </SelectContent> - </Select> - <Button - size="icon" - variant="outline" - onClick={() => - handleChangeParam( - 'order', - order === 'asc' ? 'desc' : 'asc', - ) - } - > - <MaterialSymbolsSortRounded - className={clsx( - order === 'asc' && '-scale-y-100', - )} - /> - </Button> - </div> - </div> - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground">Жанр</Label> - <Select - multiple - value={genres} - onValueChange={(value) => - handleChangeParam('genres', value) - } - > - <SelectTrigger> - <SelectValue placeholder="Виберіть жанр/жанри..." /> - </SelectTrigger> - <SelectContent> - <SelectSearch placeholder="Назва жанру..." /> - <SelectList> - {genresList && renderSelectOptions(genresList)} - <SelectEmpty>Жанрів не знайдено</SelectEmpty> - </SelectList> - </SelectContent> - </Select> - </div> - - {type !== 'watchlist' && ( - <div className="w-full"> - <div className="flex items-center justify-between gap-2"> - <Label - className="text-muted-foreground" - htmlFor="uk-translated" - > - Перекладено українською - </Label> - <Switch - checked={Boolean(lang)} - onCheckedChange={() => - handleChangeParam( - 'only_translated', - !Boolean(lang), - ) - } - id="uk-translated" - /> - </div> - </div> - )} - <BadgeFilter - title="Статус" - properties={RELEASE_STATUS} - selected={statuses} - property="statuses" - onParamChange={handleChangeParam} - /> - <BadgeFilter - title="Тип" - properties={MEDIA_TYPE} - selected={types} - property="types" - onParamChange={handleChangeParam} - /> - <BadgeFilter - title="Сезон" - properties={SEASON} - selected={seasons} - property="seasons" - onParamChange={handleChangeParam} - /> - <BadgeFilter - title="Рейтинг" - properties={AGE_RATING} - selected={ageRatings} - property="ratings" - onParamChange={handleChangeParam} - /> - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground">Студія</Label> - <Select - multiple - value={studios} - onValueChange={(value) => - handleChangeParam('studios', value) - } - onSearch={handleStudioSearch} - > - <SelectTrigger> - <SelectValue placeholder="Виберіть студію..." /> - </SelectTrigger> - <SelectContent> - <SelectSearch placeholder="Назва студії..." /> - <SelectList> - <SelectGroup> - {!isStudioListFetching && - studioList?.list.map((studio) => ( - <SelectItem - key={studio.slug} - value={studio.slug} - > - {studio.name} - </SelectItem> - ))} - <SelectEmpty> - {isStudioListFetching - ? 'Завантаження...' - : 'Студій не знайдено'} - </SelectEmpty> - </SelectGroup> - </SelectList> - </SelectContent> - </Select> - </div> - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground">Рік виходу</Label> - <div className="flex items-center gap-2"> - <YearFilterInput - years={selectingYears} - setSelectingYears={setSelectingYears} - range={RANGE.MIN} - handleChangeParam={handleChangeParam} - /> - <Slider - className="flex-1" - onValueCommit={(value) => - handleChangeParam( - 'years', - (value as number[]).map(String), - ) - } - onValueChange={(value) => - setSelectingYears( - (value as number[]).map(String), - ) - } - min={Number(DEFAULT_YEAR_START)} - max={Number(DEFAULT_YEAR_END)} - minStepsBetweenThumbs={0} - value={selectingYears.map((y) => Number(y))} - /> - <YearFilterInput - years={selectingYears} - setSelectingYears={setSelectingYears} - range={RANGE.MAX} - handleChangeParam={handleChangeParam} - /> - </div> - </div> + <div className="mt-4 flex flex-col md:mt-0"> + <ReleaseStatus /> + <Season /> + <Genre /> + <MediaType content_type={content_type} /> + <Localization /> + <Sort sort_type={sort_type} /> + <AgeRating /> + <Studio /> + <Year /> </div> <Button variant="secondary" - className="sticky bottom-4 mt-8 w-full shadow-md lg:flex" + className="my-4 w-full shadow-md md:mt-4 lg:flex" onClick={clearFilters} + asChild > - <AntDesignClearOutlined /> Очистити + <Link href={pathname}> + <AntDesignClearOutlined /> Очистити + </Link> </Button> </ScrollArea> ); diff --git a/features/filters/badge-filter.tsx b/features/filters/badge-filter.tsx index 30222a14..33736120 100644 --- a/features/filters/badge-filter.tsx +++ b/features/filters/badge-filter.tsx @@ -3,7 +3,6 @@ import MaterialSymbolsInfoRounded from '~icons/material-symbols/info-rounded'; import P from '@/components/typography/p'; import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; import { Tooltip, TooltipContent, @@ -12,7 +11,7 @@ import { interface Props { property: string; - title: string; + title?: string; properties: Hikka.FilterProperty<string>; selected: string[]; onParamChange: (key: string, value: string | string[]) => void; @@ -38,42 +37,37 @@ const BadgeFilter: FC<Props> = ({ }; return ( - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground">{title}</Label> - <div className="flex flex-wrap gap-2"> - {Object.keys(properties).map((slug) => ( - <Button - size="badge" - onClick={() => - onParamChange( - property, - handleFilterSelect(slug, selected), - ) - } - key={slug} - variant={ - selected.includes(slug) ? 'default' : 'outline' - } - > - {properties[slug].title_ua} + <div className="flex flex-wrap gap-2"> + {Object.keys(properties).map((slug) => ( + <Button + size="badge" + onClick={() => + onParamChange( + property, + handleFilterSelect(slug, selected), + ) + } + key={slug} + variant={selected.includes(slug) ? 'default' : 'outline'} + > + {properties[slug].title_ua} - {properties[slug].description && ( - <Tooltip delayDuration={0}> - <TooltipTrigger asChild> - <div> - <MaterialSymbolsInfoRounded className="text-xs opacity-30 transition duration-100 hover:opacity-100" /> - </div> - </TooltipTrigger> - <TooltipContent> - <P className="text-sm"> - {properties[slug].description} - </P> - </TooltipContent> - </Tooltip> - )} - </Button> - ))} - </div> + {properties[slug].description && ( + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <div> + <MaterialSymbolsInfoRounded className="text-xs opacity-30 transition duration-100 hover:opacity-100" /> + </div> + </TooltipTrigger> + <TooltipContent> + <P className="text-sm"> + {properties[slug].description} + </P> + </TooltipContent> + </Tooltip> + )} + </Button> + ))} </div> ); }; diff --git a/features/filters/collapsible-filter.tsx b/features/filters/collapsible-filter.tsx new file mode 100644 index 00000000..85b750c4 --- /dev/null +++ b/features/filters/collapsible-filter.tsx @@ -0,0 +1,52 @@ +import { CollapsibleProps } from '@radix-ui/react-collapsible'; +import { FC } from 'react'; +import MaterialSymbolsKeyboardArrowDownRounded from '~icons/material-symbols/keyboard-arrow-down-rounded'; +import MaterialSymbolsKeyboardArrowUpRounded from '~icons/material-symbols/keyboard-arrow-up-rounded'; + +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Label } from '@/components/ui/label'; + +import { cn } from '@/utils/utils'; + +interface Props extends CollapsibleProps {} + +const CollapsibleFilter: FC<Props> = ({ + title, + children, + className, + ...props +}) => { + return ( + <Collapsible + className={cn( + 'group space-y-4 border border-secondary/60 bg-secondary/30 px-4 py-2 data-[state=open]:mb-4 data-[state=open]:rounded-lg data-[state=open]:py-4', + '[&+div]:data-[state=open]:rounded-t-lg data-[state=open]:[&+div]:data-[state=closed]:rounded-b-lg', + 'data-[state=closed]:border-b-0 data-[state=closed]:has-[+div[data-state=open]]:mb-4 data-[state=closed]:has-[+div[data-state=open]]:rounded-b-lg data-[state=closed]:has-[+div[data-state=open]]:border-b', + 'first:rounded-t-lg last:rounded-b-lg last:!border-b', + className, + )} + {...props} + > + <CollapsibleTrigger asChild> + <div className="flex items-center justify-between gap-2"> + <Label>{title}</Label> + <Button id="title-collapse" variant="ghost" size="icon-sm"> + <MaterialSymbolsKeyboardArrowUpRounded className="size-4 group-data-[state=closed]:hidden" /> + <MaterialSymbolsKeyboardArrowDownRounded className="size-4 group-data-[state=open]:hidden" /> + </Button> + </div> + </CollapsibleTrigger> + + <CollapsibleContent className="w-full"> + {children} + </CollapsibleContent> + </Collapsible> + ); +}; + +export default CollapsibleFilter; diff --git a/features/filters/edit-filters.component.tsx b/features/filters/edit-filters.component.tsx index 7382dd7c..9e4e406a 100644 --- a/features/filters/edit-filters.component.tsx +++ b/features/filters/edit-filters.component.tsx @@ -1,298 +1,59 @@ 'use client'; -import clsx from 'clsx'; import Link from 'next/link'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { FC, useState } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { FC } from 'react'; import AntDesignClearOutlined from '~icons/ant-design/clear-outlined'; -import MaterialSymbolsSortRounded from '~icons/material-symbols/sort-rounded'; import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { - Select, - SelectContent, - SelectEmpty, - SelectGroup, - SelectItem, - SelectList, - SelectSearch, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import useUsers from '@/services/hooks/user/use-users'; -import { EDIT_STATUSES } from '@/utils/constants'; -import createQueryString from '@/utils/create-query-string'; import { cn } from '@/utils/utils'; +import ContentType from './prebuilt/content-type'; +import EditAuthor from './prebuilt/edit-author'; +import EditModerator from './prebuilt/edit-moderator'; +import EditStatus from './prebuilt/edit-status'; +import Sort from './prebuilt/sort'; + interface Props { className?: string; } -const SORT = [ - { - label: 'Номер правки', - value: 'edit_id', - }, - { - label: 'Дата створення', - value: 'created', - }, -]; - -const CONTENT_TYPES: Record<string, any> = { - anime: { - title_ua: 'Аніме', - title_en: 'Anime', - }, - character: { - title_ua: 'Персонаж', - title_en: 'Character', - }, - person: { - title_ua: 'Людина', - title_en: 'Person', - }, -}; - const EditFilters: FC<Props> = ({ className }) => { const router = useRouter(); const pathname = usePathname(); - const searchParams = useSearchParams()!; - - const content_type = searchParams.get('content_type'); - const order = searchParams.get('order'); - const sort = searchParams.get('sort') || 'edit_id'; - const edit_status = searchParams.get('edit_status'); - const author = searchParams.get('author'); - const moderator = searchParams.get('moderator'); - - const [userSearch, setUserSearch] = useState<string>(); - const { data: users, isFetching: isUsersFetching } = useUsers({ - query: userSearch, - }); const clearFilters = () => { router.replace(`${pathname}`); }; - const handleChangeParam = ( - name: string, - value: string | string[] | boolean, - ) => { - const query = createQueryString( - name, - value, - createQueryString( - 'page', - '1', - createQueryString( - 'iPage', - '1', - new URLSearchParams(searchParams), - ), - ), - ); - router.replace(`${pathname}?${query}`); - }; - - const handleUserSearch = (keyword: string) => { - if (keyword.length < 3) { - setUserSearch(undefined); - return; - } - - setUserSearch(keyword); - }; - return ( <ScrollArea className={cn( - 'flex flex-col items-start gap-8', - 'border-t border-t-transparent', - 'transition', - 'h-full lg:max-h-[calc(100vh-6rem)]', + 'flex h-full flex-col lg:max-h-[calc(100vh-6rem)]', className, )} > - <div className="flex w-full flex-col items-start gap-8 py-4"> - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground">Сортування</Label> - <div className="flex gap-2"> - <Select - value={[sort]} - onValueChange={(value) => - handleChangeParam('sort', value[0]) - } - > - <SelectTrigger className="flex-1"> - <SelectValue placeholder="Виберіть тип сортування..." /> - </SelectTrigger> - <SelectContent> - <SelectList> - <SelectGroup> - {SORT.map((item) => ( - <SelectItem - key={item.value} - value={item.value} - > - {item.label} - </SelectItem> - ))} - </SelectGroup> - </SelectList> - </SelectContent> - </Select> - <Button - size="icon" - variant="outline" - onClick={() => - handleChangeParam( - 'order', - order === 'asc' ? 'desc' : 'asc', - ) - } - > - <MaterialSymbolsSortRounded - className={clsx( - order === 'asc' && '-scale-y-100', - )} - /> - </Button> - </div> - </div> - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground">Статус</Label> - <Select - value={edit_status ? [edit_status] : undefined} - onValueChange={(value) => - handleChangeParam('edit_status', value[0]) - } - > - <SelectTrigger className="flex-1"> - <SelectValue placeholder="Виберіть статус..." /> - </SelectTrigger> - <SelectContent> - <SelectList> - <SelectGroup> - {( - Object.keys( - EDIT_STATUSES, - ) as API.EditStatus[] - ).map((item) => ( - <SelectItem key={item} value={item}> - {EDIT_STATUSES[item].title_ua} - </SelectItem> - ))} - </SelectGroup> - </SelectList> - </SelectContent> - </Select> - </div> - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground"> - Тип контенту - </Label> - <Select - value={content_type ? [content_type] : undefined} - onValueChange={(value) => - handleChangeParam('content_type', value[0]) - } - > - <SelectTrigger className="flex-1"> - <SelectValue placeholder="Виберіть тип контенту..." /> - </SelectTrigger> - <SelectContent> - <SelectList> - <SelectGroup> - {Object.keys(CONTENT_TYPES).map((item) => ( - <SelectItem key={item} value={item}> - {CONTENT_TYPES[item].title_ua} - </SelectItem> - ))} - </SelectGroup> - </SelectList> - </SelectContent> - </Select> - </div> - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground">Автор</Label> - <Select - value={author !== null ? [author] : []} - onValueChange={(value) => - handleChangeParam('author', value[0]) - } - onOpenChange={() => setUserSearch(undefined)} - onSearch={handleUserSearch} - > - <SelectTrigger className="flex-1"> - <SelectValue placeholder="Виберіть користувача..." /> - </SelectTrigger> - <SelectContent> - <SelectSearch placeholder="Імʼя користувача..." /> - <SelectList> - <SelectGroup> - {!isUsersFetching && - users?.map((item) => ( - <SelectItem - key={item.username} - value={item.username} - > - {item.username} - </SelectItem> - ))} - <SelectEmpty> - {isUsersFetching - ? 'Завантаження...' - : 'Користувачів не знайдено'} - </SelectEmpty> - </SelectGroup> - </SelectList> - </SelectContent> - </Select> - </div> - <div className="flex w-full flex-col gap-4"> - <Label className="text-muted-foreground">Модератор</Label> - <Select - value={moderator !== null ? [moderator] : []} - onValueChange={(value) => - handleChangeParam('moderator', value[0]) - } - onOpenChange={() => setUserSearch(undefined)} - onSearch={handleUserSearch} - > - <SelectTrigger className="flex-1"> - <SelectValue placeholder="Виберіть користувача..." /> - </SelectTrigger> - <SelectContent> - <SelectSearch placeholder="Імʼя користувача..." /> - <SelectList> - <SelectGroup> - {!isUsersFetching && - users?.map((item) => ( - <SelectItem - key={item.username} - value={item.username} - > - {item.username} - </SelectItem> - ))} - <SelectEmpty> - {isUsersFetching - ? 'Завантаження...' - : 'Користувачів не знайдено'} - </SelectEmpty> - </SelectGroup> - </SelectList> - </SelectContent> - </Select> - </div> + <div className="mt-4 flex flex-col md:mt-0"> + <Sort sort_type="edit" /> + <EditStatus /> + <ContentType + contentTypes={[ + 'anime', + 'manga', + 'novel', + 'character', + 'person', + ]} + /> + + <EditAuthor /> + <EditModerator /> </div> <Button variant="secondary" - className="sticky bottom-4 mt-8 w-full shadow-md lg:flex" + className="my-4 w-full shadow-md md:mt-4 lg:flex" onClick={clearFilters} asChild > diff --git a/features/filters/prebuilt/age-rating.tsx b/features/filters/prebuilt/age-rating.tsx new file mode 100644 index 00000000..391534e9 --- /dev/null +++ b/features/filters/prebuilt/age-rating.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC } from 'react'; + +import { AGE_RATING } from '@/utils/constants'; + +import BadgeFilter from '../badge-filter'; +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; +} + +const AgeRating: FC<Props> = () => { + const searchParams = useSearchParams()!; + + const ageRatings = searchParams.getAll('ratings'); + + const handleChangeParam = useChangeParam(); + + return ( + <CollapsibleFilter title="Рейтинг"> + <BadgeFilter + properties={AGE_RATING} + selected={ageRatings} + property="ratings" + onParamChange={handleChangeParam} + /> + </CollapsibleFilter> + ); +}; + +export default AgeRating; diff --git a/features/filters/prebuilt/content-type.tsx b/features/filters/prebuilt/content-type.tsx new file mode 100644 index 00000000..4ca27a04 --- /dev/null +++ b/features/filters/prebuilt/content-type.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC } from 'react'; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectList, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import { CONTENT_TYPES } from '@/utils/constants'; + +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; + contentTypes: API.ContentType[]; +} + +const ContentType: FC<Props> = ({ contentTypes }) => { + const searchParams = useSearchParams()!; + + const content_type = searchParams.get('content_type'); + + const handleChangeParam = useChangeParam(); + + return ( + <CollapsibleFilter title="Тип контенту"> + <Select + value={content_type ? [content_type] : undefined} + onValueChange={(value) => + handleChangeParam('content_type', value[0]) + } + > + <SelectTrigger className="flex-1"> + <SelectValue placeholder="Виберіть тип контенту..." /> + </SelectTrigger> + <SelectContent> + <SelectList> + <SelectGroup> + {contentTypes.map((item) => ( + <SelectItem key={item} value={item}> + {CONTENT_TYPES[item].title_ua} + </SelectItem> + ))} + </SelectGroup> + </SelectList> + </SelectContent> + </Select> + </CollapsibleFilter> + ); +}; + +export default ContentType; diff --git a/features/filters/prebuilt/edit-author.tsx b/features/filters/prebuilt/edit-author.tsx new file mode 100644 index 00000000..9368ea23 --- /dev/null +++ b/features/filters/prebuilt/edit-author.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC, useState } from 'react'; + +import { + Select, + SelectContent, + SelectEmpty, + SelectGroup, + SelectItem, + SelectList, + SelectSearch, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import useUsers from '@/services/hooks/user/use-users'; + +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; +} + +const EditAuthor: FC<Props> = ({ className }) => { + const searchParams = useSearchParams()!; + const [userSearch, setUserSearch] = useState<string>(); + const { data: users, isFetching: isUsersFetching } = useUsers({ + query: userSearch, + }); + + const author = searchParams.get('author'); + + const handleChangeParam = useChangeParam(); + + const handleUserSearch = (keyword: string) => { + if (keyword.length < 3) { + setUserSearch(undefined); + return; + } + + setUserSearch(keyword); + }; + + return ( + <CollapsibleFilter title="Автор"> + <Select + value={author !== null ? [author] : []} + onValueChange={(value) => handleChangeParam('author', value[0])} + onOpenChange={() => setUserSearch(undefined)} + onSearch={handleUserSearch} + > + <SelectTrigger className="flex-1"> + <SelectValue placeholder="Виберіть користувача..." /> + </SelectTrigger> + <SelectContent> + <SelectSearch placeholder="Імʼя користувача..." /> + <SelectList> + <SelectGroup> + {!isUsersFetching && + users?.map((item) => ( + <SelectItem + key={item.username} + value={item.username} + > + {item.username} + </SelectItem> + ))} + <SelectEmpty> + {isUsersFetching + ? 'Завантаження...' + : 'Користувачів не знайдено'} + </SelectEmpty> + </SelectGroup> + </SelectList> + </SelectContent> + </Select> + </CollapsibleFilter> + ); +}; + +export default EditAuthor; diff --git a/features/filters/prebuilt/edit-moderator.tsx b/features/filters/prebuilt/edit-moderator.tsx new file mode 100644 index 00000000..792c464b --- /dev/null +++ b/features/filters/prebuilt/edit-moderator.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC, useState } from 'react'; + +import { + Select, + SelectContent, + SelectEmpty, + SelectGroup, + SelectItem, + SelectList, + SelectSearch, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import useUsers from '@/services/hooks/user/use-users'; + +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; +} + +const EditModerator: FC<Props> = ({ className }) => { + const searchParams = useSearchParams()!; + const [userSearch, setUserSearch] = useState<string>(); + const { data: users, isFetching: isUsersFetching } = useUsers({ + query: userSearch, + }); + + const moderator = searchParams.get('moderator'); + + const handleChangeParam = useChangeParam(); + + const handleUserSearch = (keyword: string) => { + if (keyword.length < 3) { + setUserSearch(undefined); + return; + } + + setUserSearch(keyword); + }; + + return ( + <CollapsibleFilter title="Модератор"> + <Select + value={moderator !== null ? [moderator] : []} + onValueChange={(value) => + handleChangeParam('moderator', value[0]) + } + onOpenChange={() => setUserSearch(undefined)} + onSearch={handleUserSearch} + > + <SelectTrigger className="flex-1"> + <SelectValue placeholder="Виберіть користувача..." /> + </SelectTrigger> + <SelectContent> + <SelectSearch placeholder="Імʼя користувача..." /> + <SelectList> + <SelectGroup> + {!isUsersFetching && + users?.map((item) => ( + <SelectItem + key={item.username} + value={item.username} + > + {item.username} + </SelectItem> + ))} + <SelectEmpty> + {isUsersFetching + ? 'Завантаження...' + : 'Користувачів не знайдено'} + </SelectEmpty> + </SelectGroup> + </SelectList> + </SelectContent> + </Select> + </CollapsibleFilter> + ); +}; + +export default EditModerator; diff --git a/features/filters/prebuilt/edit-status.tsx b/features/filters/prebuilt/edit-status.tsx new file mode 100644 index 00000000..4b8d6e43 --- /dev/null +++ b/features/filters/prebuilt/edit-status.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC } from 'react'; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectList, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import { EDIT_STATUSES } from '@/utils/constants'; + +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; +} + +const EditStatus: FC<Props> = ({ className }) => { + const searchParams = useSearchParams()!; + + const edit_status = searchParams.get('edit_status'); + + const handleChangeParam = useChangeParam(); + + return ( + <CollapsibleFilter defaultOpen title="Статус"> + <Select + value={edit_status ? [edit_status] : undefined} + onValueChange={(value) => + handleChangeParam('edit_status', value[0]) + } + > + <SelectTrigger className="flex-1"> + <SelectValue placeholder="Виберіть статус..." /> + </SelectTrigger> + <SelectContent> + <SelectList> + <SelectGroup> + {( + Object.keys(EDIT_STATUSES) as API.EditStatus[] + ).map((item) => ( + <SelectItem key={item} value={item}> + {EDIT_STATUSES[item].title_ua} + </SelectItem> + ))} + </SelectGroup> + </SelectList> + </SelectContent> + </Select> + </CollapsibleFilter> + ); +}; + +export default EditStatus; diff --git a/features/filters/prebuilt/genre.tsx b/features/filters/prebuilt/genre.tsx new file mode 100644 index 00000000..2bc601fe --- /dev/null +++ b/features/filters/prebuilt/genre.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC } from 'react'; + +import { + Select, + SelectContent, + SelectEmpty, + SelectList, + SelectSearch, + SelectTrigger, + SelectValue, + renderSelectOptions, +} from '@/components/ui/select'; + +import useGenres from '@/services/hooks/genres/use-genres'; + +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; +} + +const Genre: FC<Props> = () => { + const searchParams = useSearchParams()!; + + const genres = searchParams.getAll('genres'); + + const handleChangeParam = useChangeParam(); + const { data: genresList } = useGenres(); + + return ( + <CollapsibleFilter defaultOpen title="Жанри"> + <Select + multiple + value={genres} + onValueChange={(value) => handleChangeParam('genres', value)} + > + <SelectTrigger> + <SelectValue placeholder="Виберіть жанр/жанри..." /> + </SelectTrigger> + <SelectContent> + <SelectSearch placeholder="Назва жанру..." /> + <SelectList> + {genresList && renderSelectOptions(genresList)} + <SelectEmpty>Жанрів не знайдено</SelectEmpty> + </SelectList> + </SelectContent> + </Select> + </CollapsibleFilter> + ); +}; + +export default Genre; diff --git a/features/filters/prebuilt/localization.tsx b/features/filters/prebuilt/localization.tsx new file mode 100644 index 00000000..65e5052e --- /dev/null +++ b/features/filters/prebuilt/localization.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; + +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; +} + +const Genre: FC<Props> = () => { + const searchParams = useSearchParams()!; + + const lang = searchParams.get('only_translated'); + + const handleChangeParam = useChangeParam(); + + return ( + <CollapsibleFilter title="Локалізація"> + <div className="flex items-center justify-between gap-2"> + <Label + className="text-muted-foreground" + htmlFor="uk-translated" + > + Перекладено українською + </Label> + <Switch + checked={Boolean(lang)} + onCheckedChange={() => + handleChangeParam('only_translated', !Boolean(lang)) + } + id="uk-translated" + /> + </div> + </CollapsibleFilter> + ); +}; + +export default Genre; diff --git a/features/filters/prebuilt/media-type.tsx b/features/filters/prebuilt/media-type.tsx new file mode 100644 index 00000000..3ff242bf --- /dev/null +++ b/features/filters/prebuilt/media-type.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC, useCallback } from 'react'; + +import { + ANIME_MEDIA_TYPE, + MANGA_MEDIA_TYPE, + NOVEL_MEDIA_TYPE, +} from '@/utils/constants'; + +import BadgeFilter from '../badge-filter'; +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; + content_type: API.ContentType; +} + +const MediaType: FC<Props> = ({ content_type }) => { + const searchParams = useSearchParams()!; + + const types = searchParams.getAll('types'); + + const handleChangeParam = useChangeParam(); + + const getMediaType = useCallback(() => { + switch (content_type) { + case 'anime': + return ANIME_MEDIA_TYPE; + case 'manga': + return MANGA_MEDIA_TYPE; + case 'novel': + return NOVEL_MEDIA_TYPE; + default: + return ANIME_MEDIA_TYPE; + } + }, [content_type]); + + return ( + <CollapsibleFilter title="Тип"> + <BadgeFilter + properties={getMediaType()} + selected={types} + property="types" + onParamChange={handleChangeParam} + /> + </CollapsibleFilter> + ); +}; + +export default MediaType; diff --git a/features/filters/prebuilt/release-status.tsx b/features/filters/prebuilt/release-status.tsx new file mode 100644 index 00000000..dbaa9f46 --- /dev/null +++ b/features/filters/prebuilt/release-status.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC } from 'react'; + +import { RELEASE_STATUS } from '@/utils/constants'; + +import BadgeFilter from '../badge-filter'; +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; +} + +const ReleaseStatus: FC<Props> = () => { + const searchParams = useSearchParams()!; + + const statuses = searchParams.getAll('statuses'); + + const handleChangeParam = useChangeParam(); + + return ( + <CollapsibleFilter defaultOpen title="Статус"> + <BadgeFilter + properties={RELEASE_STATUS} + selected={statuses} + property="statuses" + onParamChange={handleChangeParam} + /> + </CollapsibleFilter> + ); +}; + +export default ReleaseStatus; diff --git a/features/filters/prebuilt/season.tsx b/features/filters/prebuilt/season.tsx new file mode 100644 index 00000000..a10dc848 --- /dev/null +++ b/features/filters/prebuilt/season.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC } from 'react'; + +import { SEASON } from '@/utils/constants'; + +import BadgeFilter from '../badge-filter'; +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; +} + +const Season: FC<Props> = () => { + const searchParams = useSearchParams()!; + + const seasons = searchParams.getAll('seasons'); + + const handleChangeParam = useChangeParam(); + + return ( + <CollapsibleFilter defaultOpen title="Сезон"> + <BadgeFilter + properties={SEASON} + selected={seasons} + property="seasons" + onParamChange={handleChangeParam} + /> + </CollapsibleFilter> + ); +}; + +export default Season; diff --git a/features/filters/prebuilt/sort.tsx b/features/filters/prebuilt/sort.tsx new file mode 100644 index 00000000..b5fb9606 --- /dev/null +++ b/features/filters/prebuilt/sort.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC, useCallback } from 'react'; +import MaterialSymbolsSortRounded from '~icons/material-symbols/sort-rounded'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectList, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import { cn } from '@/utils/utils'; + +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +const SORT_CONTENT = [ + { + label: 'Загальна оцінка', + value: 'score', + }, + { + label: 'Дата релізу', + value: 'start_date', + }, + { + label: 'Тип', + value: 'media_type', + }, +]; + +const SORT_WATCHLIST = [ + ...SORT_CONTENT, + { + label: 'К-сть епізодів', + value: 'watch_episodes', + }, + { + label: 'Дата додавання', + value: 'watch_created', + }, + { + label: 'Власна оцінка', + value: 'watch_score', + }, +]; + +const SORT_READLIST = [ + ...SORT_CONTENT, + { + label: 'Дата додавання', + value: 'read_created', + }, + { + label: 'Власна оцінка', + value: 'read_score', + }, +]; + +const SORT_EDITLIST = [ + { + label: 'Номер правки', + value: 'edit_id', + }, + { + label: 'Дата створення', + value: 'created', + }, +]; + +interface Props { + className?: string; + sort_type: 'anime' | 'watch' | 'manga' | 'novel' | 'read' | 'edit'; +} + +const Sort: FC<Props> = ({ sort_type }) => { + const searchParams = useSearchParams()!; + + const order = searchParams.get('order'); + const sort = searchParams.get('sort'); + + const handleChangeParam = useChangeParam(); + + const getSort = useCallback(() => { + switch (sort_type) { + case 'anime': + return SORT_CONTENT; + case 'watch': + return SORT_WATCHLIST; + case 'manga': + return SORT_CONTENT; + case 'novel': + return SORT_CONTENT; + case 'read': + return SORT_READLIST; + case 'edit': + return SORT_EDITLIST; + default: + return SORT_CONTENT; + } + }, [sort_type]); + + return ( + <CollapsibleFilter title="Сортування"> + <div className="flex gap-2"> + <Select + value={sort ? [sort] : undefined} + onValueChange={(value) => + handleChangeParam('sort', value[0]) + } + > + <SelectTrigger className="flex-1"> + <SelectValue placeholder="Виберіть сортування..." /> + </SelectTrigger> + <SelectContent> + <SelectList> + <SelectGroup> + {getSort().map((item) => ( + <SelectItem + key={item.value} + value={item.value} + > + {item.label} + </SelectItem> + ))} + </SelectGroup> + </SelectList> + </SelectContent> + </Select> + <Button + size="icon" + variant="outline" + onClick={() => + handleChangeParam( + 'order', + order === 'asc' ? 'desc' : 'asc', + ) + } + > + <MaterialSymbolsSortRounded + className={cn(order === 'asc' && '-scale-y-100')} + /> + </Button> + </div> + </CollapsibleFilter> + ); +}; + +export default Sort; diff --git a/features/filters/prebuilt/studio.tsx b/features/filters/prebuilt/studio.tsx new file mode 100644 index 00000000..433fb13e --- /dev/null +++ b/features/filters/prebuilt/studio.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC, useState } from 'react'; + +import { + Select, + SelectContent, + SelectEmpty, + SelectGroup, + SelectItem, + SelectList, + SelectSearch, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import useCompanies from '@/services/hooks/companies/use-companies'; + +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; + +interface Props { + className?: string; +} + +const Studio: FC<Props> = () => { + const searchParams = useSearchParams()!; + + const studios = searchParams.getAll('studios'); + + const [studioSearch, setStudioSearch] = useState<string>(); + const { data: studioList, isFetching: isStudioListFetching } = useCompanies( + { + type: 'studio', + query: studioSearch, + }, + ); + + const handleChangeParam = useChangeParam(); + + const handleStudioSearch = (keyword: string) => { + if (keyword.length < 3) { + setStudioSearch(undefined); + return; + } + + setStudioSearch(keyword); + }; + + return ( + <CollapsibleFilter title="Студія"> + <Select + multiple + value={studios} + onValueChange={(value) => handleChangeParam('studios', value)} + onSearch={handleStudioSearch} + > + <SelectTrigger> + <SelectValue placeholder="Виберіть студію..." /> + </SelectTrigger> + <SelectContent> + <SelectSearch placeholder="Назва студії..." /> + <SelectList> + <SelectGroup> + {!isStudioListFetching && + studioList?.list.map((studio) => ( + <SelectItem + key={studio.slug} + value={studio.slug} + > + {studio.name} + </SelectItem> + ))} + <SelectEmpty> + {isStudioListFetching + ? 'Завантаження...' + : 'Студій не знайдено'} + </SelectEmpty> + </SelectGroup> + </SelectList> + </SelectContent> + </Select> + </CollapsibleFilter> + ); +}; + +export default Studio; diff --git a/features/filters/prebuilt/year.tsx b/features/filters/prebuilt/year.tsx new file mode 100644 index 00000000..0f2326ea --- /dev/null +++ b/features/filters/prebuilt/year.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC, useEffect, useState } from 'react'; + +import { Slider } from '@/components/ui/slider'; + +import CollapsibleFilter from '../collapsible-filter'; +import useChangeParam from '../use-change-param'; +import YearFilterInput from '../year-filter-input'; + +const YEARS: [number, number] = [1965, new Date().getFullYear()]; +const DEFAULT_YEAR_START = YEARS[0].toString(); +const DEFAULT_YEAR_END = YEARS[1].toString(); + +enum RANGE { + MIN = 'min', + MAX = 'max', +} + +interface Props { + className?: string; +} + +const Year: FC<Props> = () => { + const searchParams = useSearchParams()!; + + const years = searchParams.getAll('years'); + + const [selectingYears, setSelectingYears] = useState<string[]>( + years.length > 0 ? years : YEARS.map((y) => String(y)), + ); + + const handleChangeParam = useChangeParam(); + + useEffect(() => { + if (JSON.stringify(selectingYears) !== JSON.stringify(years)) { + setSelectingYears( + years.length > 0 ? years : YEARS.map((y) => String(y)), + ); + } + }, [searchParams]); + + return ( + <CollapsibleFilter title="Рік виходу"> + <div className="flex items-center gap-2"> + <YearFilterInput + years={selectingYears} + setSelectingYears={setSelectingYears} + range={RANGE.MIN} + handleChangeParam={handleChangeParam} + /> + <Slider + className="flex-1" + onValueCommit={(value) => + handleChangeParam( + 'years', + (value as number[]).map(String), + ) + } + onValueChange={(value) => + setSelectingYears((value as number[]).map(String)) + } + min={Number(DEFAULT_YEAR_START)} + max={Number(DEFAULT_YEAR_END)} + minStepsBetweenThumbs={0} + value={selectingYears.map((y) => Number(y))} + /> + <YearFilterInput + years={selectingYears} + setSelectingYears={setSelectingYears} + range={RANGE.MAX} + handleChangeParam={handleChangeParam} + /> + </div> + </CollapsibleFilter> + ); +}; + +export default Year; diff --git a/features/filters/read-filters.component.tsx b/features/filters/read-filters.component.tsx new file mode 100644 index 00000000..1a366e41 --- /dev/null +++ b/features/filters/read-filters.component.tsx @@ -0,0 +1,64 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { FC } from 'react'; +import AntDesignClearOutlined from '~icons/ant-design/clear-outlined'; + +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +import { cn } from '@/utils/utils'; + +import Genre from './prebuilt/genre'; +import Localization from './prebuilt/localization'; +import MediaType from './prebuilt/media-type'; +import ReleaseStatus from './prebuilt/release-status'; +import Sort from './prebuilt/sort'; +import Year from './prebuilt/year'; + +interface Props { + className?: string; + content_type: API.ContentType; + sort_type: 'manga' | 'novel' | 'read'; +} + +const ReadFilters: FC<Props> = ({ className, content_type, sort_type }) => { + const router = useRouter(); + const pathname = usePathname(); + + const clearFilters = () => { + router.replace(`${pathname}`); + // setSelectingYears(YEARS.map((y) => String(y))); + }; + + return ( + <ScrollArea + className={cn( + 'flex h-full flex-col lg:max-h-[calc(100vh-6rem)]', + className, + )} + > + <div className="mt-4 flex flex-col md:mt-0"> + <ReleaseStatus /> + <Genre /> + <MediaType content_type={content_type} /> + <Localization /> + <Sort sort_type={sort_type} /> + <Year /> + </div> + <Button + variant="secondary" + className="my-4 w-full shadow-md md:mt-4 lg:flex" + onClick={clearFilters} + asChild + > + <Link href={pathname}> + <AntDesignClearOutlined /> Очистити + </Link> + </Button> + </ScrollArea> + ); +}; + +export default ReadFilters; diff --git a/features/filters/schedule-filters.component.tsx b/features/filters/schedule-filters.component.tsx index ba8ad128..62f56351 100644 --- a/features/filters/schedule-filters.component.tsx +++ b/features/filters/schedule-filters.component.tsx @@ -86,7 +86,7 @@ const ScheduleFilters: FC<Props> = ({ className }) => { return ( <div className={cn( - 'flex flex-col items-end gap-8 lg:flex-row lg:gap-4', + 'flex flex-col items-end gap-8 lg:flex-row lg:gap-4 mt-4 md:mt-0"', className, )} > @@ -174,7 +174,7 @@ const ScheduleFilters: FC<Props> = ({ className }) => { </Select> </div> {loggedUser && ( - <div className="flex h-12 items-center justify-between gap-2 rounded-md border border-secondary bg-secondary/30 p-4"> + <div className="flex h-12 items-center justify-between gap-2 rounded-md border bg-secondary/30 p-4"> <Label className="line-clamp-1 min-w-0 truncate text-muted-foreground"> Аніме у списку </Label> diff --git a/features/filters/use-change-param.ts b/features/filters/use-change-param.ts new file mode 100644 index 00000000..5656fd55 --- /dev/null +++ b/features/filters/use-change-param.ts @@ -0,0 +1,33 @@ +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import createQueryString from '@/utils/create-query-string'; + +const useChangeParam = () => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams()!; + + const handleChangeParam = ( + name: string, + value: string | string[] | boolean, + ) => { + const query = createQueryString( + name, + value, + createQueryString( + 'page', + '1', + createQueryString( + 'iPage', + '1', + new URLSearchParams(searchParams), + ), + ), + ); + router.replace(`${pathname}?${query}`); + }; + + return handleChangeParam; +}; + +export default useChangeParam; diff --git a/features/home/collections.component.tsx b/features/home/collections.component.tsx index c66071d0..931c965b 100644 --- a/features/home/collections.component.tsx +++ b/features/home/collections.component.tsx @@ -4,11 +4,11 @@ import Link from 'next/link'; import { FC } from 'react'; import MaterialSymbolsAddRounded from '~icons/material-symbols/add-rounded'; +import ContentCard from '@/components/content-card/content-card'; import Block from '@/components/ui/block'; import { Button } from '@/components/ui/button'; import Header from '@/components/ui/header'; - -import CollectionItem from '@/features/users/user-profile/user-collections/collection-item'; +import Stack from '@/components/ui/stack'; import useSession from '@/services/hooks/auth/use-session'; import useCollections from '@/services/hooks/collections/use-collections'; @@ -26,7 +26,7 @@ const Collections: FC<Props> = ({ className }) => { page: 1, }); - const filteredCollections = collections?.list?.slice(0, 3); + const filteredCollections = collections?.list?.slice(0, 8); return ( <Block className={cn(className)}> @@ -39,12 +39,27 @@ const Collections: FC<Props> = ({ className }) => { </Button> )} </Header> - <div className="flex flex-col gap-6"> + <Stack size={8}> {filteredCollections && filteredCollections.map((item) => ( - <CollectionItem data={item} key={item.reference} /> + <ContentCard + key={item.reference} + title={item.title} + image={item.collection[0].content.image} + href={`/collections/${item.reference}`} + titleClassName={cn( + item.spoiler && 'blur hover:blur-none', + )} + containerClassName={cn( + item.nsfw && 'blur hover:blur-none', + )} + leftSubtitle={(item.nsfw && '+18') || undefined} + rightSubtitle={ + (item.spoiler && 'Спойлери') || undefined + } + /> ))} - </div> + </Stack> </Block> ); }; diff --git a/features/home/comments.component.tsx b/features/home/comments.component.tsx index af0ecd3b..7273bd48 100644 --- a/features/home/comments.component.tsx +++ b/features/home/comments.component.tsx @@ -2,10 +2,11 @@ import { FC } from 'react'; -import ContentCard from '@/components/content-card/content-card'; +import GlobalComment from '@/components/comments/global-comment'; import Block from '@/components/ui/block'; +import Card from '@/components/ui/card'; import Header from '@/components/ui/header'; -import HorizontalCard from '@/components/ui/horizontal-card'; +import Stack from '@/components/ui/stack'; import useLatestComments from '@/services/hooks/comments/use-latest-comments'; import { cn } from '@/utils/utils'; @@ -20,28 +21,37 @@ const Comments: FC<Props> = ({ className }) => { return ( <Block className={cn(className)}> <Header title="Коментарі" href="/comments/latest" /> - <div className="flex flex-col gap-6"> + <Stack size={3} className="grid-min-20 "> {comments?.map((item) => ( - <HorizontalCard - image={item.author.avatar} - imageRatio={1} - description={item.text} - descriptionHref={`/comments/${item.content_type}/${item.slug}`} - key={item.created} - title={item.author.username} - href={`/u/${item.author.username}`} - createdAt={item.created} - > - <ContentCard - className="w-10" - poster={item.image} - href={`/comments/${item.content_type}/${item.slug}`} + <Card key={item.reference}> + <GlobalComment + href={`/comments/${item.content_type}/${item.preview.slug}`} + comment={item} /> - </HorizontalCard> + </Card> ))} - </div> + </Stack> </Block> ); }; export default Comments; + +{ + /* <HorizontalCard + image={item.author.avatar} + imageRatio={1} + description={item.text} + descriptionHref={`/comments/${item.content_type}/${item.slug}`} + key={item.created} + title={item.author.username} + href={`/u/${item.author.username}`} + createdAt={item.created} +> + <ContentCard + className="w-10" + image={item.image} + href={`/comments/${item.content_type}/${item.slug}`} + /> +</HorizontalCard> */ +} diff --git a/features/home/history.component.tsx b/features/home/history.component.tsx index 192b3015..f2e7de66 100644 --- a/features/home/history.component.tsx +++ b/features/home/history.component.tsx @@ -1,10 +1,12 @@ 'use client'; +import Link from 'next/link'; import { FC } from 'react'; import HistoryItem from '@/components/history-item'; import Block from '@/components/ui/block'; -import Header from '@/components/ui/header'; +import { Button } from '@/components/ui/button'; +import Card from '@/components/ui/card'; import NotFound from '@/components/ui/not-found'; import useSession from '@/services/hooks/auth/use-session'; @@ -23,11 +25,7 @@ const History: FC<Props> = ({ className }) => { return ( <Block className={cn(className)}> - <Header - title="Історія" - href={`/u/${user?.username}/history?type=following`} - /> - <div className="flex flex-col gap-6"> + <Card className="flex flex-col gap-6"> {filteredHistory?.map((item) => ( <HistoryItem data={item} key={item.reference} withUser /> ))} @@ -37,7 +35,12 @@ const History: FC<Props> = ({ className }) => { description="Історія оновиться після змін у Вашому списку, або у списку користувачів, яких Ви відстежуєте" /> )} - </div> + <Button asChild size="sm" variant="outline"> + <Link href={`/u/${user?.username}/history?type=following`}> + Переглянути всі + </Link> + </Button> + </Card> </Block> ); }; diff --git a/features/home/ongoings.component.tsx b/features/home/ongoings.component.tsx index 87705b56..361803f0 100644 --- a/features/home/ongoings.component.tsx +++ b/features/home/ongoings.component.tsx @@ -10,6 +10,7 @@ import Header from '@/components/ui/header'; import Stack from '@/components/ui/stack'; import useAnimeCatalog from '@/services/hooks/anime/use-anime-catalog'; +import getCurrentSeason from '@/utils/get-current-season'; import { cn } from '@/utils/utils'; interface Props { @@ -17,8 +18,13 @@ interface Props { } const Ongoings: FC<Props> = ({ className }) => { + const currentSeason = getCurrentSeason(); + const year = String(new Date().getFullYear()); + const { list, isLoading } = useAnimeCatalog({ - status: ['ongoing'], + season: [currentSeason!], + years: [year, year], + score: [7, 8, 9, 10], page: 1, iPage: 1, }); diff --git a/features/home/profile.component.tsx b/features/home/profile/anime.tsx similarity index 72% rename from features/home/profile.component.tsx rename to features/home/profile/anime.tsx index 9b948141..8e8f320e 100644 --- a/features/home/profile.component.tsx +++ b/features/home/profile/anime.tsx @@ -1,7 +1,8 @@ 'use client'; import Link from 'next/link'; -import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Fragment, useEffect, useState } from 'react'; import MaterialSymbolsAddRounded from '~icons/material-symbols/add-rounded'; import MaterialSymbolsRemoveRounded from '~icons/material-symbols/remove-rounded'; import MaterialSymbolsSettingsOutline from '~icons/material-symbols/settings-outline'; @@ -9,10 +10,9 @@ import MaterialSymbolsSettingsOutline from '~icons/material-symbols/settings-out import ContentCard from '@/components/content-card/content-card'; import H5 from '@/components/typography/h5'; import P from '@/components/typography/p'; -import Block from '@/components/ui/block'; import { Button } from '@/components/ui/button'; import Card from '@/components/ui/card'; -import Header from '@/components/ui/header'; +import { Label } from '@/components/ui/label'; import NotFound from '@/components/ui/not-found'; import { Progress } from '@/components/ui/progress'; import Stack from '@/components/ui/stack'; @@ -28,16 +28,26 @@ import useSession from '@/services/hooks/auth/use-session'; import useAddWatch from '@/services/hooks/watch/use-add-watch'; import useWatchList from '@/services/hooks/watch/use-watch-list'; import { useModalContext } from '@/services/providers/modal-provider'; +import { ANIME_MEDIA_TYPE } from '@/utils/constants'; +import getDeclensionWord from '@/utils/get-declension-word'; import { cn } from '@/utils/utils'; -const Profile = () => { +const EPISODES_DECLENSION: [string, string, string] = [ + 'епізод', + 'епізоди', + 'епізодів', +]; + +const Anime = () => { + const router = useRouter(); const { openModal } = useModalContext(); const [selectedSlug, setSelectedSlug] = useState<string>(); const { user: loggedUser } = useSession(); - const { list, queryKey } = useWatchList({ + const { list } = useWatchList({ username: String(loggedUser?.username), watch_status: 'watching', + sort: ['watch_updated:desc'], }); const selectedWatch = @@ -50,6 +60,14 @@ const Profile = () => { reset, } = useAddWatch(); + const handleSelect = (slug: string) => { + if (slug === selectedSlug) { + router.push(`/anime/${slug}`); + } + + setSelectedSlug(slug); + }; + const openWatchEditModal = () => { if (selectedWatch) { openModal({ @@ -108,8 +126,7 @@ const Profile = () => { }, [selectedSlug]); return ( - <Block> - <Header title="Профіль" href={`/u/${loggedUser?.username}`} /> + <Fragment> {list?.length === 0 && ( <NotFound title={ @@ -121,18 +138,18 @@ const Profile = () => { } description="Додайте аніме у список Дивлюсь, щоб відстежувати їх прогрес" /> - )} + )}{' '} {list && list?.length > 0 && ( - <Card> - <Stack className="grid-min-3 gap-4 lg:gap-4"> + <Card className="h-full"> + <Stack className="grid-min-3 grid-max-3 gap-4 lg:gap-4"> {list?.map((item) => ( <Tooltip key={item.anime.slug}> <TooltipTrigger asChild> <ContentCard onClick={() => - setSelectedSlug(item.anime.slug) + handleSelect(item.anime.slug) } - poster={item.anime.poster} + image={item.anime.image} className={cn( 'transition-opacity', selectedWatch?.anime.slug !== @@ -148,10 +165,44 @@ const Profile = () => { ))} </Stack> <Link - className="w-fit" + className="w-fit flex-1" href={`/anime/${selectedWatch?.anime.slug}`} > <H5>{selectedWatch?.anime.title}</H5> + + <div className="mt-1 flex cursor-pointer items-center gap-2"> + {selectedWatch?.anime.year && ( + <Fragment> + <Label className="cursor-pointer text-xs text-muted-foreground"> + {selectedWatch?.anime.year} + </Label> + </Fragment> + )} + {selectedWatch?.anime.media_type && ( + <Fragment> + <div className="size-1 rounded-full bg-muted-foreground" /> + <Label className="cursor-pointer text-xs text-muted-foreground"> + { + ANIME_MEDIA_TYPE[ + selectedWatch?.anime.media_type + ].title_ua + } + </Label> + </Fragment> + )} + {selectedWatch?.anime.episodes_total && ( + <Fragment> + <div className="size-1 rounded-full bg-muted-foreground" /> + <Label className="cursor-pointer text-xs text-muted-foreground"> + {selectedWatch?.anime.episodes_total}{' '} + {getDeclensionWord( + selectedWatch?.anime.episodes_total, + EPISODES_DECLENSION, + )} + </Label> + </Fragment> + )} + </div> </Link> <div className="flex w-full flex-col gap-2"> <P className="text-sm text-muted-foreground"> @@ -160,7 +211,8 @@ const Profile = () => { ? variables?.params?.episodes : selectedWatch?.episodes || 0} </span> - /{selectedWatch?.anime.episodes_total || '?'} + /{selectedWatch?.anime.episodes_total || '?'}{' '} + епізодів </P> <Progress @@ -216,8 +268,8 @@ const Profile = () => { </div> </Card> )} - </Block> + </Fragment> ); }; -export default Profile; +export default Anime; diff --git a/features/home/profile/manga.tsx b/features/home/profile/manga.tsx new file mode 100644 index 00000000..a6fa0a74 --- /dev/null +++ b/features/home/profile/manga.tsx @@ -0,0 +1,283 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Fragment, useEffect, useState } from 'react'; +import MaterialSymbolsAddRounded from '~icons/material-symbols/add-rounded'; +import MaterialSymbolsRemoveRounded from '~icons/material-symbols/remove-rounded'; +import MaterialSymbolsSettingsOutline from '~icons/material-symbols/settings-outline'; + +import ContentCard from '@/components/content-card/content-card'; +import H5 from '@/components/typography/h5'; +import P from '@/components/typography/p'; +import { Button } from '@/components/ui/button'; +import Card from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import NotFound from '@/components/ui/not-found'; +import { Progress } from '@/components/ui/progress'; +import Stack from '@/components/ui/stack'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +import ReadEditModal from '@/features/modals/read-edit-modal'; + +import useSession from '@/services/hooks/auth/use-session'; +import useAddRead from '@/services/hooks/read/use-add-read'; +import useReadList from '@/services/hooks/read/use-read-list'; +import { useModalContext } from '@/services/providers/modal-provider'; +import { MANGA_MEDIA_TYPE } from '@/utils/constants'; +import getDeclensionWord from '@/utils/get-declension-word'; +import { cn } from '@/utils/utils'; + +const CHAPTERS_DECLENSION: [string, string, string] = [ + 'розділ', + 'розділи', + 'розділів', +]; + +const Manga = () => { + const router = useRouter(); + const { openModal } = useModalContext(); + const [selectedSlug, setSelectedSlug] = useState<string>(); + const { user: loggedUser } = useSession(); + + const { list } = useReadList({ + username: String(loggedUser?.username), + read_status: 'reading', + sort: ['read_updated:desc'], + content_type: 'manga', + }); + + const selectedRead = + list?.find((item) => item.content.slug === selectedSlug) || list?.[0]; + + const { mutate: mutateAddRead, variables, isPending, reset } = useAddRead(); + + const handleSelect = (slug: string) => { + if (slug === selectedSlug) { + router.push(`/manga/${slug}`); + } + + setSelectedSlug(slug); + }; + + const openReadEditModal = () => { + if (selectedRead) { + openModal({ + content: ( + <ReadEditModal + slug={selectedRead.content.slug} + content_type={'manga'} + /> + ), + className: '!max-w-xl', + title: selectedRead.content.title, + forceModal: true, + }); + } + }; + + const handleAddChapter = () => { + if (selectedRead) { + const chapters = + (variables?.params?.chapters || selectedRead.chapters) + 1; + + if ( + selectedRead.content.chapters && + chapters > selectedRead.content.chapters + ) + return; + + mutateAddRead({ + params: { + note: selectedRead.note, + volumes: selectedRead.volumes, + rereads: selectedRead.rereads, + score: selectedRead.score, + content_type: 'manga', + status: + chapters === selectedRead.content.chapters + ? 'completed' + : 'reading', + slug: selectedRead.content.slug, + chapters, + }, + }); + } + }; + + const handleRemoveChapter = () => { + if (selectedRead) { + const chapters = + (variables?.params?.chapters || selectedRead.chapters) - 1; + + if (chapters < 0) return; + + mutateAddRead({ + params: { + note: selectedRead.note, + volumes: selectedRead.volumes, + rereads: selectedRead.rereads, + score: selectedRead.score, + status: selectedRead.status, + content_type: 'manga', + slug: selectedRead.content.slug, + chapters, + }, + }); + } + }; + + useEffect(() => { + reset(); + }, [selectedSlug]); + + return ( + <Fragment> + {list?.length === 0 && ( + <NotFound + title={ + <span> + Список <span className="font-extrabold">Читаю</span>{' '} + порожній + </span> + } + description="Додайте манґу у список Читаю, щоб відстежувати їх прогрес" + /> + )}{' '} + {list && list?.length > 0 && ( + <Card className="h-full"> + <Stack className="grid-min-3 grid-max-3 gap-4 lg:gap-4"> + {list?.map((item) => ( + <Tooltip key={item.content.slug}> + <TooltipTrigger asChild> + <ContentCard + onClick={() => + handleSelect(item.content.slug) + } + image={item.content.image} + className={cn( + 'transition-opacity', + selectedRead?.content.slug !== + item.content.slug && + 'opacity-30 hover:opacity-60', + )} + /> + </TooltipTrigger> + <TooltipContent className="max-w-48 truncate"> + {item.content.title} + </TooltipContent> + </Tooltip> + ))} + </Stack> + <Link + className="w-fit flex-1" + href={`/manga/${selectedRead?.content.slug}`} + > + <H5>{selectedRead?.content.title}</H5> + <div className="mt-1 flex cursor-pointer items-center gap-2"> + {selectedRead?.content.year && ( + <Fragment> + <Label className="cursor-pointer text-xs text-muted-foreground"> + {selectedRead?.content.year} + </Label> + </Fragment> + )} + {selectedRead?.content.media_type && ( + <Fragment> + <div className="size-1 rounded-full bg-muted-foreground" /> + <Label className="cursor-pointer text-xs text-muted-foreground"> + { + MANGA_MEDIA_TYPE[ + selectedRead?.content + .media_type as API.MangaMediaType + ].title_ua + } + </Label> + </Fragment> + )} + {selectedRead?.content.chapters && ( + <Fragment> + <div className="size-1 rounded-full bg-muted-foreground" /> + <Label className="cursor-pointer text-xs text-muted-foreground"> + {selectedRead?.content?.chapters}{' '} + {getDeclensionWord( + selectedRead?.content.chapters, + CHAPTERS_DECLENSION, + )} + </Label> + </Fragment> + )} + </div> + </Link> + <div className="flex w-full flex-col gap-2"> + <P className="text-sm text-muted-foreground"> + <span className="font-bold text-foreground"> + {isPending + ? variables?.params?.chapters + : selectedRead?.chapters || 0} + </span> + /{selectedRead?.content.chapters || '?'} розділів + </P> + + <Progress + className="h-2" + value={ + isPending + ? variables?.params?.chapters + : selectedRead?.chapters + } + max={ + selectedRead?.content.chapters || + selectedRead?.chapters + } + /> + </div> + <div className="grid grid-cols-2 gap-4"> + <Button + variant="outline" + size="sm" + onClick={openReadEditModal} + > + <MaterialSymbolsSettingsOutline /> + Налаштування + </Button> + <div className="flex"> + <Button + className="flex-1 rounded-r-none" + onClick={handleAddChapter} + variant="secondary" + size="sm" + // disabled={isPending} + > + <MaterialSymbolsAddRounded /> + <div className="flex gap-1"> + <span className="hidden sm:block"> + Додати + </span> + <span className="capitalize sm:normal-case"> + розділ + </span> + </div> + </Button> + <Button + className="rounded-l-none" + onClick={handleRemoveChapter} + variant="secondary" + size="icon-md" + // disabled={isPending} + > + <MaterialSymbolsRemoveRounded /> + </Button> + </div> + </div> + </Card> + )} + </Fragment> + ); +}; + +export default Manga; diff --git a/features/home/profile/novel.tsx b/features/home/profile/novel.tsx new file mode 100644 index 00000000..26e6e42b --- /dev/null +++ b/features/home/profile/novel.tsx @@ -0,0 +1,276 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Fragment, useEffect, useState } from 'react'; +import MaterialSymbolsAddRounded from '~icons/material-symbols/add-rounded'; +import MaterialSymbolsRemoveRounded from '~icons/material-symbols/remove-rounded'; +import MaterialSymbolsSettingsOutline from '~icons/material-symbols/settings-outline'; + +import ContentCard from '@/components/content-card/content-card'; +import H5 from '@/components/typography/h5'; +import P from '@/components/typography/p'; +import { Button } from '@/components/ui/button'; +import Card from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import NotFound from '@/components/ui/not-found'; +import { Progress } from '@/components/ui/progress'; +import Stack from '@/components/ui/stack'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +import ReadEditModal from '@/features/modals/read-edit-modal'; + +import useSession from '@/services/hooks/auth/use-session'; +import useAddRead from '@/services/hooks/read/use-add-read'; +import useReadList from '@/services/hooks/read/use-read-list'; +import { useModalContext } from '@/services/providers/modal-provider'; +import { NOVEL_MEDIA_TYPE } from '@/utils/constants'; +import getDeclensionWord from '@/utils/get-declension-word'; +import { cn } from '@/utils/utils'; + +const CHAPTERS_DECLENSION: [string, string, string] = [ + 'розділ', + 'розділи', + 'розділів', +]; + +const Novel = () => { + const router = useRouter(); + const { openModal } = useModalContext(); + const [selectedSlug, setSelectedSlug] = useState<string>(); + const { user: loggedUser } = useSession(); + + const { list } = useReadList({ + username: String(loggedUser?.username), + read_status: 'reading', + sort: ['read_updated:desc'], + content_type: 'novel', + }); + + const selectedRead = + list?.find((item) => item.content.slug === selectedSlug) || list?.[0]; + + const { mutate: mutateAddRead, variables, isPending, reset } = useAddRead(); + + const handleSelect = (slug: string) => { + if (slug === selectedSlug) { + router.push(`/manga/${slug}`); + } + + setSelectedSlug(slug); + }; + + const openReadEditModal = () => { + if (selectedRead) { + openModal({ + content: ( + <ReadEditModal + slug={selectedRead.content.slug} + content_type={'novel'} + /> + ), + className: '!max-w-xl', + title: selectedRead.content.title, + forceModal: true, + }); + } + }; + + const handleAddChapter = () => { + if (selectedRead) { + const chapters = + (variables?.params?.chapters || selectedRead.chapters) + 1; + + if ( + selectedRead.content.chapters && + chapters > selectedRead.content.chapters + ) + return; + + mutateAddRead({ + params: { + ...selectedRead, + content_type: 'novel', + status: + chapters === selectedRead.content.chapters + ? 'completed' + : 'reading', + slug: selectedRead.content.slug, + chapters, + }, + }); + } + }; + + const handleRemoveChapter = () => { + if (selectedRead) { + const chapters = + (variables?.params?.chapters || selectedRead.chapters) - 1; + + if (chapters < 0) return; + + mutateAddRead({ + params: { + ...selectedRead, + content_type: 'novel', + slug: selectedRead.content.slug, + chapters, + }, + }); + } + }; + + useEffect(() => { + reset(); + }, [selectedSlug]); + + return ( + <Fragment> + {list?.length === 0 && ( + <NotFound + title={ + <span> + Список <span className="font-extrabold">Читаю</span>{' '} + порожній + </span> + } + description="Додайте ранобе у список Читаю, щоб відстежувати їх прогрес" + /> + )}{' '} + {list && list?.length > 0 && ( + <Card className="h-full"> + <Stack className="grid-min-3 grid-max-3 gap-4 lg:gap-4"> + {list?.map((item) => ( + <Tooltip key={item.content.slug}> + <TooltipTrigger asChild> + <ContentCard + onClick={() => + handleSelect(item.content.slug) + } + image={item.content.image} + className={cn( + 'transition-opacity', + selectedRead?.content.slug !== + item.content.slug && + 'opacity-30 hover:opacity-60', + )} + /> + </TooltipTrigger> + <TooltipContent className="max-w-48 truncate"> + {item.content.title} + </TooltipContent> + </Tooltip> + ))} + </Stack> + <Link + className="w-fit flex-1" + href={`/novel/${selectedRead?.content.slug}`} + > + <H5>{selectedRead?.content.title}</H5> + <div className="mt-1 flex cursor-pointer items-center gap-2"> + {selectedRead?.content.year && ( + <Fragment> + <Label className="cursor-pointer text-xs text-muted-foreground"> + {selectedRead?.content.year} + </Label> + </Fragment> + )} + {selectedRead?.content.media_type && ( + <Fragment> + <div className="size-1 rounded-full bg-muted-foreground" /> + <Label className="cursor-pointer text-xs text-muted-foreground"> + { + NOVEL_MEDIA_TYPE[ + selectedRead?.content + .media_type as API.NovelMediaType + ].title_ua + } + </Label> + </Fragment> + )} + {selectedRead?.content.chapters && ( + <Fragment> + <div className="size-1 rounded-full bg-muted-foreground" /> + <Label className="cursor-pointer text-xs text-muted-foreground"> + {selectedRead?.content?.chapters}{' '} + {getDeclensionWord( + selectedRead?.content.chapters, + CHAPTERS_DECLENSION, + )} + </Label> + </Fragment> + )} + </div> + </Link> + <div className="flex w-full flex-col gap-2"> + <P className="text-sm text-muted-foreground"> + <span className="font-bold text-foreground"> + {isPending + ? variables?.params?.chapters + : selectedRead?.chapters || 0} + </span> + /{selectedRead?.content.chapters || '?'} розділів + </P> + + <Progress + className="h-2" + value={ + isPending + ? variables?.params?.chapters + : selectedRead?.chapters + } + max={ + selectedRead?.content.chapters || + selectedRead?.chapters + } + /> + </div> + <div className="grid grid-cols-2 gap-4"> + <Button + variant="outline" + size="sm" + onClick={openReadEditModal} + > + <MaterialSymbolsSettingsOutline /> + Налаштування + </Button> + <div className="flex"> + <Button + className="flex-1 rounded-r-none" + onClick={handleAddChapter} + variant="secondary" + size="sm" + // disabled={isPending} + > + <MaterialSymbolsAddRounded /> + <div className="flex gap-1"> + <span className="hidden sm:block"> + Додати + </span> + <span className="capitalize sm:normal-case"> + розділ + </span> + </div> + </Button> + <Button + className="rounded-l-none" + onClick={handleRemoveChapter} + variant="secondary" + size="icon-md" + // disabled={isPending} + > + <MaterialSymbolsRemoveRounded /> + </Button> + </div> + </div> + </Card> + )} + </Fragment> + ); +}; + +export default Novel; diff --git a/features/home/profile/profile.component.tsx b/features/home/profile/profile.component.tsx new file mode 100644 index 00000000..60524e8d --- /dev/null +++ b/features/home/profile/profile.component.tsx @@ -0,0 +1,43 @@ +import MaterialSymbolsMenuBookRounded from '~icons/material-symbols/menu-book-rounded'; +import MaterialSymbolsPalette from '~icons/material-symbols/palette'; +import MaterialSymbolsPlayArrowRounded from '~icons/material-symbols/play-arrow-rounded'; + +import Block from '@/components/ui/block'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import Anime from './anime'; +import Manga from './manga'; +import Novel from './novel'; + +const Profile = () => { + return ( + <Block> + <Tabs defaultValue="anime" className="flex flex-col flex-1"> + <TabsList className="w-full"> + <TabsTrigger value="anime" className="flex-1 flex gap-2"> + <MaterialSymbolsPlayArrowRounded className="size-4" />{' '} + Аніме + </TabsTrigger> + <TabsTrigger value="manga" className="flex-1 flex gap-2"> + <MaterialSymbolsPalette className="size-4" /> Манґа + </TabsTrigger> + <TabsTrigger value="novel" className="flex-1 flex gap-2"> + <MaterialSymbolsMenuBookRounded className="size-4" />{' '} + Ранобе + </TabsTrigger> + </TabsList> + <TabsContent value="anime" className="flex-1"> + <Anime /> + </TabsContent> + <TabsContent value="manga" className="flex-1"> + <Manga /> + </TabsContent> + <TabsContent value="novel" className="flex-1"> + <Novel /> + </TabsContent> + </Tabs> + </Block> + ); +}; + +export default Profile; diff --git a/features/home/schedule/schedule-item.tsx b/features/home/schedule/schedule-item.tsx index a4d162cb..afe62bc1 100644 --- a/features/home/schedule/schedule-item.tsx +++ b/features/home/schedule/schedule-item.tsx @@ -18,7 +18,7 @@ const ScheduleItem: FC<Props> = ({ item }) => { size="sm" title={item.anime.title!} href={`/anime/${item.anime.slug}`} - image={item.anime.poster} + image={item.anime.image} > <div className="flex w-full flex-col gap-4 sm:flex-row sm:items-end"> <div className="flex flex-1 flex-col"> diff --git a/features/home/schedule/schedule.component.tsx b/features/home/schedule/schedule.component.tsx index 3c241ec1..2c10492e 100644 --- a/features/home/schedule/schedule.component.tsx +++ b/features/home/schedule/schedule.component.tsx @@ -1,15 +1,36 @@ 'use client'; +import { useSearchParams } from 'next/navigation'; + import Block from '@/components/ui/block'; import Header from '@/components/ui/header'; import Stack from '@/components/ui/stack'; import useAnimeSchedule from '@/services/hooks/stats/use-anime-schedule'; +import getCurrentSeason from '@/utils/get-current-season'; import ScheduleItem from './schedule-item'; const Schedule = () => { - const { list } = useAnimeSchedule(); + const searchParams = useSearchParams(); + + const only_watch = searchParams.get('only_watch') + ? Boolean(searchParams.get('only_watch')) + : undefined; + const season = + (searchParams.get('season') as API.Season) || getCurrentSeason()!; + const year = searchParams.get('year') || String(new Date().getFullYear()); + const status = ( + searchParams.getAll('status').length > 0 + ? searchParams.getAll('status') + : ['ongoing', 'announced'] + ) as API.Status[]; + + const { list } = useAnimeSchedule({ + airing_season: [season, year], + status, + only_watch, + }); const filteredList = list ?.filter((item) => item.airing_at * 1000 > Date.now()) diff --git a/features/manga/manga-list-navbar/manga-list-navbar.component.tsx b/features/manga/manga-list-navbar/manga-list-navbar.component.tsx new file mode 100644 index 00000000..57a983bb --- /dev/null +++ b/features/manga/manga-list-navbar/manga-list-navbar.component.tsx @@ -0,0 +1,26 @@ +import { Suspense } from 'react'; + +import ReadFiltersModal from '@/features/modals/read-filters-modal'; + +import { cn } from '@/utils/utils'; + +import Search from './search'; + +const MangaListNavbar = () => { + return ( + <div + className={cn( + 'flex items-end gap-2 border-b border-b-transparent bg-transparent transition md:gap-4', + )} + > + <Suspense> + <Search /> + </Suspense> + <div className="lg:hidden"> + <ReadFiltersModal sort_type="manga" content_type="manga" /> + </div> + </div> + ); +}; + +export default MangaListNavbar; diff --git a/features/manga/manga-list-navbar/search.tsx b/features/manga/manga-list-navbar/search.tsx new file mode 100644 index 00000000..2657fc97 --- /dev/null +++ b/features/manga/manga-list-navbar/search.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; + +import { Input } from '@/components/ui/input'; + +import createQueryString from '@/utils/create-query-string'; + +const Search = () => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams()!; + + const [search, setSearch] = useState(searchParams.get('search')); + + const handleChangeSearch = (value: string) => { + const query = createQueryString( + 'search', + value, + createQueryString( + 'page', + '1', + createQueryString( + 'iPage', + '1', + new URLSearchParams(searchParams), + ), + ), + ); + setSearch(value); + + router.replace(`${pathname}?${query}`); + }; + + return ( + <div className="flex flex-1 flex-col gap-4"> + <Input + value={search || ''} + onChange={(event) => handleChangeSearch(event.target.value)} + type="text" + placeholder="Введіть назву манґи..." + /> + </div> + ); +}; + +export default Search; diff --git a/features/manga/manga-list/manga-list-skeleton.tsx b/features/manga/manga-list/manga-list-skeleton.tsx new file mode 100644 index 00000000..32e28c37 --- /dev/null +++ b/features/manga/manga-list/manga-list-skeleton.tsx @@ -0,0 +1,15 @@ +import { range } from '@antfu/utils'; + +import SkeletonCard from '@/components/skeletons/content-card'; + +const MangaListSkeleton = () => { + return ( + <div className="grid grid-cols-2 gap-4 md:grid-cols-5 lg:gap-8"> + {range(1, 20).map((v) => ( + <SkeletonCard key={v} /> + ))} + </div> + ); +}; + +export default MangaListSkeleton; diff --git a/features/manga/manga-list/manga-list.component.tsx b/features/manga/manga-list/manga-list.component.tsx new file mode 100644 index 00000000..17cc4736 --- /dev/null +++ b/features/manga/manga-list/manga-list.component.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC } from 'react'; + +import FiltersNotFound from '@/components/filters-not-found'; +import LoadMoreButton from '@/components/load-more-button'; +import MangaCard from '@/components/manga-card'; +import Block from '@/components/ui/block'; +import Pagination from '@/components/ui/pagination'; +import Stack from '@/components/ui/stack'; + +import useMangaCatalog from '@/services/hooks/manga/use-manga-catalog'; + +import MangaListSkeleton from './manga-list-skeleton'; +import { useNextPage, useUpdatePage } from './manga-list.hooks'; + +interface Props {} + +const MangaList: FC<Props> = () => { + const searchParams = useSearchParams(); + + const query = searchParams.get('search'); + const media_type = searchParams.getAll('types'); + const status = searchParams.getAll('statuses'); + + const years = searchParams.getAll('years'); + const genres = searchParams.getAll('genres'); + + const only_translated = searchParams.get('only_translated'); + + const sort = searchParams.get('sort') || 'score'; + const order = searchParams.get('order') || 'desc'; + + const page = searchParams.get('page'); + const iPage = searchParams.get('iPage'); + + const dataKeys = { + query, + media_type, + status, + years, + genres, + only_translated: Boolean(only_translated), + sort: sort ? [`${sort}:${order}`] : undefined, + page: Number(page), + iPage: Number(iPage), + }; + + const { + fetchNextPage, + isFetchingNextPage, + isLoading, + hasNextPage, + list, + pagination, + } = useMangaCatalog(dataKeys); + + const updatePage = useUpdatePage(dataKeys); + const nextPage = useNextPage({ fetchNextPage, pagination }); + + if (isLoading && !isFetchingNextPage) { + return <MangaListSkeleton />; + } + + if (list === undefined || list.length === 0) { + return <FiltersNotFound />; + } + + return ( + <Block> + <Stack extended={true} size={5} extendedSize={5}> + {list.map((manga: API.Manga) => { + return <MangaCard key={manga.slug} manga={manga} />; + })} + </Stack> + {hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={nextPage} + /> + )} + {list && pagination && pagination.pages > 1 && ( + <div className="sticky bottom-2 z-10 flex items-center justify-center"> + <div className="w-fit rounded-lg border border-secondary/60 bg-background p-2 shadow"> + <Pagination + page={Number(iPage)} + pages={pagination.pages} + setPage={updatePage} + /> + </div> + </div> + )} + </Block> + ); +}; + +export default MangaList; diff --git a/features/manga/manga-list/manga-list.hooks.ts b/features/manga/manga-list/manga-list.hooks.ts new file mode 100644 index 00000000..a6866c22 --- /dev/null +++ b/features/manga/manga-list/manga-list.hooks.ts @@ -0,0 +1,63 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import useMangaCatalog, { + Props, +} from '@/services/hooks/manga/use-manga-catalog'; +import createQueryString from '@/utils/create-query-string'; + +export const useUpdatePage = ({ page, iPage }: Props) => { + const queryClient = useQueryClient(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return (newPage: number) => { + if (newPage !== Number(page) || newPage !== Number(iPage)) { + queryClient.removeQueries({ + queryKey: ['manga-list', page, {}], + exact: false, + }); + const query = createQueryString( + 'iPage', + String(newPage), + createQueryString( + 'page', + String(newPage), + new URLSearchParams(searchParams), + ), + ); + router.push(`${pathname}?${query.toString()}`, { scroll: true }); + } + }; +}; + +interface UseLoadInfinitePageProps { + pagination?: ReturnType<typeof useMangaCatalog>['pagination']; + fetchNextPage: ReturnType<typeof useMangaCatalog>['fetchNextPage']; +} + +export const useNextPage = ({ + fetchNextPage, + pagination, +}: UseLoadInfinitePageProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + + return () => { + if (pagination) { + const query = createQueryString( + 'iPage', + String(pagination.page + 1), + new URLSearchParams(searchParams), + ); + + router.replace(`${pathname}?${query.toString()}`, { + scroll: false, + }); + + fetchNextPage(); + } + }; +}; diff --git a/features/manga/manga-view/actions/actions.component.tsx b/features/manga/manga-view/actions/actions.component.tsx new file mode 100644 index 00000000..5b0dc122 --- /dev/null +++ b/features/manga/manga-view/actions/actions.component.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import CommentsButton from '@/components/comments-button'; +import ReadListButton from '@/components/readlist-button/readlist-button'; + +import useSession from '@/services/hooks/auth/use-session'; +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; + +import ReadStats from './read-stats'; + +const Actions: FC = () => { + const params = useParams(); + const { user } = useSession(); + const { data: manga } = useMangaInfo({ slug: String(params.slug) }); + + return ( + <div className="flex flex-col gap-12"> + <div className="flex flex-col gap-4"> + <ReadListButton + content_type="manga" + disabled={!user} + additional + slug={String(params.slug)} + /> + <ReadStats /> + {manga && ( + <CommentsButton + comments_count={manga.comments_count} + slug={manga?.slug} + content_type="manga" + /> + )} + </div> + </div> + ); +}; + +export default Actions; diff --git a/features/manga/manga-view/actions/read-stats.tsx b/features/manga/manga-view/actions/read-stats.tsx new file mode 100644 index 00000000..0f0a3707 --- /dev/null +++ b/features/manga/manga-view/actions/read-stats.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import MaterialSymbolsAddRounded from '~icons/material-symbols/add-rounded'; +import MaterialSymbolsRemoveRounded from '~icons/material-symbols/remove-rounded'; + +import H3 from '@/components/typography/h3'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Progress } from '@/components/ui/progress'; +import Rating from '@/components/ui/rating'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; +import useAddRead from '@/services/hooks/read/use-add-read'; +import useRead from '@/services/hooks/read/use-read'; + +const ReadStats = () => { + const params = useParams(); + + const { data: read, isError: readError } = useRead({ + slug: String(params.slug), + content_type: 'manga', + }); + const { data } = useMangaInfo({ slug: String(params.slug) }); + + const { mutate: mutateAddRead, variables, isPending } = useAddRead(); + + const handleAddEpisode = () => { + if (read) { + const chapters = (variables?.params?.chapters || read.chapters) + 1; + + if (read.content.chapters && chapters > read.content.chapters) + return; + + let status = read.status; + + if (chapters === read.content.chapters) { + status = 'completed'; + } + + if (!read.chapters && read.status === 'planned') { + status = 'reading'; + } + + mutateAddRead({ + params: { + content_type: 'manga', + note: read.note, + volumes: read.volumes, + rereads: read.rereads, + score: read.score, + status, + slug: read.content.slug, + chapters, + }, + }); + } + }; + + const handleRemoveEpisode = () => { + if (read) { + const chapters = (variables?.params?.chapters || read.chapters) - 1; + + if (chapters < 0) return; + + mutateAddRead({ + params: { + note: read.note, + volumes: read.volumes, + rereads: read.rereads, + score: read.score, + status: read.status, + content_type: 'manga', + slug: read.content.slug, + chapters, + }, + }); + } + }; + + const handleRating = (value: number) => { + if (read) { + mutateAddRead({ + params: { + note: read.note, + volumes: read.volumes, + chapters: read.chapters, + rereads: read.rereads, + status: read.status, + content_type: 'manga', + slug: read.content.slug, + score: value * 2, + }, + }); + } + }; + + if (!read || readError || !data) { + return null; + } + + return ( + <div className="flex flex-col gap-4"> + <div className="flex justify-between gap-4 rounded-lg border border-secondary/60 bg-secondary/30 p-4"> + <Rating + // className="rating-md lg:flex" + onChange={handleRating} + totalStars={5} + precision={0.5} + value={read.score ? read.score / 2 : 0} + /> + <H3> + {read.score} + <Label className="text-sm font-normal text-muted-foreground"> + /10 + </Label> + </H3> + </div> + <div className="rounded-lg border border-secondary/60 bg-secondary/30 p-4"> + <div className="flex justify-between gap-2 overflow-hidden"> + <Label className="min-h-[24px] self-center overflow-hidden text-ellipsis"> + Розділи + </Label> + <div className="inline-flex"> + <Button + variant="secondary" + size="icon-sm" + className="rounded-r-none" + onClick={handleRemoveEpisode} + > + <MaterialSymbolsRemoveRounded /> + </Button> + <Button + variant="secondary" + size="icon-sm" + className="rounded-l-none" + onClick={handleAddEpisode} + > + <MaterialSymbolsAddRounded /> + </Button> + </div> + </div> + <H3> + {isPending ? variables?.params?.chapters : read.chapters} + <Label className="text-sm font-normal text-muted-foreground"> + /{read.content.chapters || '?'} + </Label> + </H3> + <Progress + className="mt-2 h-2" + max={read.content.chapters || read.chapters} + value={ + isPending ? variables?.params?.chapters : read.chapters + } + /> + </div> + </div> + ); +}; + +export default ReadStats; diff --git a/features/manga/manga-view/characters/characters.component.tsx b/features/manga/manga-view/characters/characters.component.tsx new file mode 100644 index 00000000..df4bc16d --- /dev/null +++ b/features/manga/manga-view/characters/characters.component.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import LoadMoreButton from '@/components/load-more-button'; + +import useMangaCharacters from '@/services/hooks/manga/use-manga-characters'; + +import MainCharacters from './main-characters'; +import OtherCharacters from './other-characters'; + +interface Props { + extended?: boolean; +} + +const Characters: FC<Props> = ({ extended }) => { + const params = useParams(); + const { fetchNextPage, hasNextPage, isFetchingNextPage, ref } = + useMangaCharacters({ slug: String(params.slug) }); + + return ( + <div className="flex flex-col gap-12"> + <MainCharacters extended={extended} /> + {extended && <OtherCharacters extended={extended} />} + {extended && hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + )} + </div> + ); +}; + +export default Characters; diff --git a/features/manga/manga-view/characters/main-characters.tsx b/features/manga/manga-view/characters/main-characters.tsx new file mode 100644 index 00000000..12158b4d --- /dev/null +++ b/features/manga/manga-view/characters/main-characters.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useMangaCharacters from '@/services/hooks/manga/use-manga-characters'; + +import CharacterCard from '../../../../components/character-card'; + +interface Props { + extended?: boolean; +} + +const MainCharacters: FC<Props> = ({ extended }) => { + const params = useParams(); + const { list } = useMangaCharacters({ slug: String(params.slug) }); + + if (!list || list.length === 0) { + return null; + } + + const main = list.filter((ch) => ch.main); + + return ( + <Block> + <Header + title={'Головні Персонажі'} + href={!extended ? params.slug + '/characters' : undefined} + /> + <Stack size={5} className="grid-min-6" extended={extended}> + {(extended ? main : main.slice(0, 5)).map((ch) => ( + <CharacterCard + key={ch.character.slug} + character={ch.character} + /> + ))} + </Stack> + </Block> + ); +}; + +export default MainCharacters; diff --git a/features/manga/manga-view/characters/other-characters.tsx b/features/manga/manga-view/characters/other-characters.tsx new file mode 100644 index 00000000..6557bd82 --- /dev/null +++ b/features/manga/manga-view/characters/other-characters.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import CharacterCard from '@/components/character-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useMangaCharacters from '@/services/hooks/manga/use-manga-characters'; + +interface Props { + extended?: boolean; +} + +const OtherCharacters: FC<Props> = ({ extended }) => { + const params = useParams(); + const { list } = useMangaCharacters({ slug: String(params.slug) }); + + if (!list || list.length === 0) { + return null; + } + + const other = list.filter((ch) => !ch.main); + + if (other.length === 0) { + return null; + } + + return ( + <Block> + <Header title={'Другорядні Персонажі'} /> + <Stack size={5} className="grid-min-6" extended={extended}> + {other.map((ch) => ( + <CharacterCard + key={ch.character.slug} + character={ch.character} + /> + ))} + </Stack> + </Block> + ); +}; + +export default OtherCharacters; diff --git a/features/manga/manga-view/cover.component.tsx b/features/manga/manga-view/cover.component.tsx new file mode 100644 index 00000000..66078ec8 --- /dev/null +++ b/features/manga/manga-view/cover.component.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import ContentCard from '@/components/content-card/content-card'; +import FavoriteButton from '@/components/favorite-button'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; + +const Cover: FC = () => { + const params = useParams(); + const { data: manga } = useMangaInfo({ slug: String(params.slug) }); + + return ( + <div className="flex items-center px-16 md:px-48 lg:px-0"> + <ContentCard imageProps={{ priority: true }} image={manga?.image}> + <div className="absolute bottom-2 right-2 z-[1]"> + <FavoriteButton + slug={String(params.slug)} + content_type="manga" + /> + </div> + + <div className="absolute bottom-0 left-0 h-24 w-full bg-gradient-to-t from-black to-transparent" /> + </ContentCard> + </div> + ); +}; + +export default Cover; diff --git a/features/manga/manga-view/description.component.tsx b/features/manga/manga-view/description.component.tsx new file mode 100644 index 00000000..519fda53 --- /dev/null +++ b/features/manga/manga-view/description.component.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useState } from 'react'; + +import MDViewer from '@/components/markdown/viewer/MD-viewer'; +import TextExpand from '@/components/text-expand'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; + +const Description = () => { + const [active, setActive] = useState<'synopsis_ua' | 'synopsis_en'>( + 'synopsis_ua', + ); + const params = useParams(); + const { data } = useMangaInfo({ slug: String(params.slug) }); + + if (!data || (!data.synopsis_ua && !data.synopsis_en)) { + return null; + } + + return ( + <Block> + <Header title="Опис"> + <ToggleGroup + type="single" + value={active} + onValueChange={(value: 'synopsis_ua' | 'synopsis_en') => + value && setActive(value) + } + variant="outline" + size="badge" + > + {data.synopsis_ua && ( + <ToggleGroupItem + value="synopsis_ua" + aria-label="Опис українскою" + > + UA + </ToggleGroupItem> + )} + {data.synopsis_en && ( + <ToggleGroupItem + value="synopsis_en" + aria-label="Опис англійською" + > + EN + </ToggleGroupItem> + )} + </ToggleGroup> + </Header> + <TextExpand> + <MDViewer> + {data[active] || data.synopsis_ua || data.synopsis_en} + </MDViewer> + </TextExpand> + </Block> + ); +}; + +export default Description; diff --git a/features/manga/manga-view/details/chapters.tsx b/features/manga/manga-view/details/chapters.tsx new file mode 100644 index 00000000..5feb97dd --- /dev/null +++ b/features/manga/manga-view/details/chapters.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; + +interface Props { + chapters: number; +} + +const Chapters: FC<Props> = ({ chapters }) => { + if (!chapters) { + return null; + } + + return ( + <div className="flex flex-wrap"> + <div className="w-24"> + <Label className="text-muted-foreground">Розділи:</Label> + </div> + <div className="flex-1"> + <Label>{chapters}</Label> + </div> + </div> + ); +}; + +export default Chapters; diff --git a/features/manga/manga-view/details/details.component.tsx b/features/manga/manga-view/details/details.component.tsx new file mode 100644 index 00000000..b07d2059 --- /dev/null +++ b/features/manga/manga-view/details/details.component.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useParams } from 'next/navigation'; + +import Block from '@/components/ui/block'; +import Card from '@/components/ui/card'; +import Header from '@/components/ui/header'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; + +import Chapters from './chapters'; +import Magazines from './magazines'; +import MediaType from './media-type'; +import Status from './status'; +import Volumes from './volumes'; + +const Details = () => { + const params = useParams(); + + const { data } = useMangaInfo({ slug: String(params.slug) }); + + if (!data) { + return null; + } + + return ( + <Block> + <Header title="Деталі" /> + <Card> + <MediaType media_type={data.media_type} /> + <Status status={data.status} /> + <Volumes volumes={data.volumes} /> + <Chapters chapters={data.chapters} /> + <Magazines magazines={data.magazines} /> + </Card> + </Block> + ); +}; + +export default Details; diff --git a/features/manga/manga-view/details/magazines.tsx b/features/manga/manga-view/details/magazines.tsx new file mode 100644 index 00000000..c7bcc4a2 --- /dev/null +++ b/features/manga/manga-view/details/magazines.tsx @@ -0,0 +1,33 @@ +import Link from 'next/link'; +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; + +interface Props { + magazines: API.Magazine[]; +} + +const Magazines: FC<Props> = ({ magazines }) => { + if (!magazines || magazines.length === 0) { + return null; + } + + return ( + <div className="flex flex-wrap"> + <div className="w-24"> + <Label className="text-muted-foreground">Видавець:</Label> + </div> + <div className="flex flex-1 items-center gap-1"> + {magazines.map((magazine) => ( + <Label key={magazine.slug}> + <Link href={`/manga?magazines=${magazine.slug}`}> + {magazine.name_en} + </Link> + </Label> + ))} + </div> + </div> + ); +}; + +export default Magazines; diff --git a/features/manga/manga-view/details/media-type.tsx b/features/manga/manga-view/details/media-type.tsx new file mode 100644 index 00000000..86b1d70b --- /dev/null +++ b/features/manga/manga-view/details/media-type.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; + +import { MANGA_MEDIA_TYPE } from '@/utils/constants'; + +interface Props { + media_type: API.MangaMediaType; +} + +const MediaType: FC<Props> = ({ media_type }) => { + if (!media_type) { + return null; + } + + return ( + <div className="flex flex-wrap"> + <div className="w-24"> + <Label className="text-muted-foreground">Тип:</Label> + </div> + <div className="flex-1"> + <Label>{MANGA_MEDIA_TYPE[media_type].title_ua}</Label> + </div> + </div> + ); +}; + +export default MediaType; diff --git a/features/manga/manga-view/details/status.tsx b/features/manga/manga-view/details/status.tsx new file mode 100644 index 00000000..92c61d47 --- /dev/null +++ b/features/manga/manga-view/details/status.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; + +import { RELEASE_STATUS } from '@/utils/constants'; + +interface Props { + status: API.Status; +} + +const Status: FC<Props> = ({ status }) => { + if (!status) { + return null; + } + + return ( + <div className="flex flex-wrap items-center"> + <div className="w-24"> + <Label className="text-muted-foreground">Статус:</Label> + </div> + <div className="flex-1"> + <Badge variant="status" bgColor={RELEASE_STATUS[status].color}> + {RELEASE_STATUS[status].title_ua} + </Badge> + </div> + </div> + ); +}; + +export default Status; diff --git a/features/manga/manga-view/details/volumes.tsx b/features/manga/manga-view/details/volumes.tsx new file mode 100644 index 00000000..1292437e --- /dev/null +++ b/features/manga/manga-view/details/volumes.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; + +interface Props { + volumes: number; +} + +const Volumes: FC<Props> = ({ volumes }) => { + if (!volumes) { + return null; + } + + return ( + <div className="flex flex-wrap"> + <div className="w-24"> + <Label className="text-muted-foreground">Томи:</Label> + </div> + <div className="flex-1"> + <Label>{volumes}</Label> + </div> + </div> + ); +}; + +export default Volumes; diff --git a/features/manga/manga-view/franchise.component.tsx b/features/manga/manga-view/franchise.component.tsx new file mode 100644 index 00000000..5418b158 --- /dev/null +++ b/features/manga/manga-view/franchise.component.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import AnimeCard from '@/components/anime-card'; +import MangaCard from '@/components/manga-card'; +import NovelCard from '@/components/novel-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; +import useFranchise from '@/services/hooks/related/use-franchise'; + +interface Props { + extended?: boolean; +} + +const Franchise: FC<Props> = ({ extended }) => { + const params = useParams(); + const { data: manga } = useMangaInfo({ slug: String(params.slug) }); + + const { data: franchise } = useFranchise({ + slug: String(params.slug), + content_type: 'manga', + }); + + if (!manga || !manga.has_franchise) { + return null; + } + + if (!franchise) { + return null; + } + + const filteredData = extended ? franchise.list : franchise.list.slice(0, 4); + + return ( + <Block> + <Header + title={`Пов’язане`} + href={!extended ? params.slug + '/franchise' : undefined} + /> + <Stack extended={extended} size={4} className="grid-min-10"> + {filteredData.map((content) => { + if (content.data_type === 'anime') { + return <AnimeCard key={content.slug} anime={content} />; + } + + if (content.data_type === 'manga') { + return <MangaCard key={content.slug} manga={content} />; + } + + if (content.data_type === 'novel') { + return <NovelCard key={content.slug} novel={content} />; + } + })} + </Stack> + </Block> + ); +}; + +export default Franchise; diff --git a/features/manga/manga-view/links/links.component.tsx b/features/manga/manga-view/links/links.component.tsx new file mode 100644 index 00000000..e657bb88 --- /dev/null +++ b/features/manga/manga-view/links/links.component.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC, useState } from 'react'; +import MaterialSymbolsInfoIRounded from '~icons/material-symbols/info-i-rounded'; +import MaterialSymbolsPlayArrowRounded from '~icons/material-symbols/play-arrow-rounded'; + +import TextExpand from '@/components/text-expand'; +import P from '@/components/typography/p'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import HorizontalCard from '@/components/ui/horizontal-card'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; +import { useModalContext } from '@/services/providers/modal-provider'; + +interface Props { + extended?: boolean; +} + +const Links: FC<Props> = ({ extended }) => { + const [isExpanded, setIsExpanded] = useState(false); + const [active, setActive] = useState<API.External['type']>('general'); + const params = useParams(); + const { openModal } = useModalContext(); + const { data: manga } = useMangaInfo({ slug: String(params.slug) }); + + if (!manga) { + return null; + } + + if (manga.external.length === 0) { + return null; + } + + const readLinksData = manga.external.filter((l) => l.type === 'read'); + const generalLinksData = manga.external.filter((l) => l.type === 'general'); + + const linksData = active === 'general' ? generalLinksData : readLinksData; + + const handleChangeActive = (value: API.External['type']) => { + if (value) { + setActive(value); + setIsExpanded(false); + } + }; + + return ( + <Block> + <Header title="Посилання"> + <ToggleGroup + type="single" + value={active} + onValueChange={handleChangeActive} + variant="outline" + size="badge" + > + <ToggleGroupItem + value="general" + aria-label="Загальні посилання" + > + <MaterialSymbolsInfoIRounded /> + </ToggleGroupItem> + {readLinksData.length > 0 && ( + <ToggleGroupItem + value="read" + aria-label="Посилання для читання" + > + <MaterialSymbolsPlayArrowRounded /> + </ToggleGroupItem> + )} + </ToggleGroup> + </Header> + <TextExpand + expanded={isExpanded} + setExpanded={setIsExpanded} + className="max-h-40" + > + <div className="flex flex-col gap-4"> + {linksData.map((link) => ( + <HorizontalCard + key={link.url} + title={link.text} + href={link.url} + imageRatio={1} + imageContainerClassName="w-8" + descriptionClassName="break-all" + image={<P>{link.text[0]}</P>} + /> + ))} + </div> + </TextExpand> + </Block> + ); +}; + +export default Links; diff --git a/features/manga/manga-view/read-stats/read-stats.component.tsx b/features/manga/manga-view/read-stats/read-stats.component.tsx new file mode 100644 index 00000000..2f615fce --- /dev/null +++ b/features/manga/manga-view/read-stats/read-stats.component.tsx @@ -0,0 +1,40 @@ +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; + +import Readlist from './readlist'; +import Score from './score'; + +const ReadStats = () => { + return ( + <Block> + <Header title="Статистика"> + <ToggleGroup + type="single" + value="MAL" + variant="outline" + size="badge" + > + <ToggleGroupItem value="MAL" aria-label="MAL"> + MAL + </ToggleGroupItem> + </ToggleGroup> + </Header> + <Tabs defaultValue="readlist"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="readlist">У списках</TabsTrigger> + <TabsTrigger value="score">Оцінки</TabsTrigger> + </TabsList> + <TabsContent value="readlist"> + <Readlist /> + </TabsContent> + <TabsContent value="score"> + <Score /> + </TabsContent> + </Tabs> + </Block> + ); +}; + +export default ReadStats; diff --git a/features/manga/manga-view/read-stats/readlist.tsx b/features/manga/manga-view/read-stats/readlist.tsx new file mode 100644 index 00000000..20683d8d --- /dev/null +++ b/features/manga/manga-view/read-stats/readlist.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { createElement } from 'react'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; +import { READ_STATUS } from '@/utils/constants'; + +import Stats from './stats'; + +const Readlist = () => { + const params = useParams(); + const { data } = useMangaInfo({ slug: String(params.slug) }); + + if (!data) { + return null; + } + + const sumStats = + data.stats.completed + + data.stats.on_hold + + data.stats.dropped + + data.stats.planned + + data.stats.reading; + + const stats = Object.keys(data.stats).reduce( + (acc: Hikka.ListStat[], stat) => { + if (!stat.includes('score')) { + const status = READ_STATUS[stat as API.ReadStatus]; + const percentage = + (100 * data.stats[stat as API.StatType]) / sumStats; + + acc.push({ + percentage, + value: data.stats[stat as API.StatType], + icon: status.icon && createElement(status.icon), + color: status.color!, + }); + } + + return acc; + }, + [], + ); + + return <Stats stats={stats} />; +}; + +export default Readlist; diff --git a/features/manga/manga-view/read-stats/score.tsx b/features/manga/manga-view/read-stats/score.tsx new file mode 100644 index 00000000..b8a99cb6 --- /dev/null +++ b/features/manga/manga-view/read-stats/score.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useParams } from 'next/navigation'; + +import Small from '@/components/typography/small'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; + +import Stats from './stats'; + +const Score = () => { + const params = useParams(); + const { data } = useMangaInfo({ slug: String(params.slug) }); + + if (!data) { + return null; + } + + const sumStats = + data.stats.score_1 + + data.stats.score_2 + + data.stats.score_3 + + data.stats.score_4 + + data.stats.score_5 + + data.stats.score_6 + + data.stats.score_7 + + data.stats.score_8 + + data.stats.score_9 + + data.stats.score_10; + + const stats = Object.keys(data.stats) + .reverse() + .reduce((acc: Hikka.ListStat[], stat) => { + if ( + stat.includes('score') && + data.stats[stat as API.StatType] > 0 + ) { + const percentage = + (100 * data.stats[stat as API.StatType]) / sumStats; + + acc.push({ + icon: <Small>{stat.split('score_')[1]}</Small>, + percentage, + value: data.stats[stat as API.StatType], + }); + } + + return acc; + }, []); + + return <Stats stats={stats} />; +}; + +export default Score; diff --git a/features/manga/manga-view/read-stats/stats.tsx b/features/manga/manga-view/read-stats/stats.tsx new file mode 100644 index 00000000..2ebdc811 --- /dev/null +++ b/features/manga/manga-view/read-stats/stats.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { FC } from 'react'; +import { NumericFormat } from 'react-number-format'; + +import Small from '@/components/typography/small'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +interface Props { + stats: Hikka.ListStat[]; +} + +const Stats: FC<Props> = ({ stats }) => { + return ( + <div className="relative overflow-hidden rounded-lg border border-secondary/60 bg-secondary/30 p-4"> + <div className="flex flex-col justify-center gap-2"> + {stats.map((stat) => { + return ( + <Tooltip + key={`${stat.value}-${stat.percentage}`} + delayDuration={0} + > + <TooltipTrigger asChild> + <div className="flex items-center justify-between gap-2"> + <div className="flex w-full flex-1 items-center gap-2"> + {stat.icon && ( + <div className="flex size-6 items-center justify-center rounded-md bg-secondary"> + {stat.icon} + </div> + )} + <div className="relative h-2 w-full flex-1 overflow-hidden rounded-md"> + <div + className="absolute bottom-0 left-0 size-full bg-primary opacity-10" + style={{ + backgroundColor: stat.color, + }} + /> + <div + className="absolute bottom-0 left-0 flex h-2 w-full items-end justify-center bg-primary" + style={{ + backgroundColor: stat.color, + width: `${stat.percentage}%`, + }} + ></div> + </div> + </div> + <Small className="w-14 text-right text-muted-foreground"> + <NumericFormat + thousandSeparator + displayType="text" + value={stat.value} + decimalScale={1} + /> + </Small> + </div> + </TooltipTrigger> + <TooltipContent align="center" side="left"> + {stat.percentage.toFixed(2)}% + </TooltipContent> + </Tooltip> + ); + })} + </div> + </div> + ); +}; + +export default Stats; diff --git a/features/manga/manga-view/staff.component.tsx b/features/manga/manga-view/staff.component.tsx new file mode 100644 index 00000000..9c8e5721 --- /dev/null +++ b/features/manga/manga-view/staff.component.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import PersonCard from '@/components/person-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; + +interface Props { + extended?: boolean; +} + +const Staff: FC<Props> = ({ extended }) => { + const params = useParams(); + const { data } = useMangaInfo({ slug: String(params.slug) }); + + if (!data || data.authors.length === 0) { + return null; + } + + const authors = data.authors; + + const filteredData = extended ? authors : authors.slice(0, 4); + + return ( + <Block> + <Header + title="Автори" + href={!extended ? params.slug + '/staff' : undefined} + /> + <Stack size={4} extended={extended}> + {filteredData.map((staff) => ( + <PersonCard + key={staff.person.slug} + person={staff.person} + roles={staff.roles} + /> + ))} + </Stack> + </Block> + ); +}; + +export default Staff; diff --git a/features/manga/manga-view/title.component.tsx b/features/manga/manga-view/title.component.tsx new file mode 100644 index 00000000..cd9356a5 --- /dev/null +++ b/features/manga/manga-view/title.component.tsx @@ -0,0 +1,91 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import MaterialSymbolsStarRounded from '~icons/material-symbols/star-rounded'; + +import EditButton from '@/components/edit-button'; +import H2 from '@/components/typography/h2'; +import P from '@/components/typography/p'; + +import useSession from '@/services/hooks/auth/use-session'; +import useMangaInfo from '@/services/hooks/manga/use-manga-info'; + +const Title = () => { + const { user: loggedUser } = useSession(); + const params = useParams(); + const { data } = useMangaInfo({ slug: String(params.slug) }); + + if (!data) { + return null; + } + + return ( + <div className="flex flex-col gap-4"> + <div className="flex justify-between gap-4"> + <div> + <div className="flex gap-4"> + <H2> + {data.title}{' '} + {data.start_date && ( + <span className="font-sans font-normal"> + ( + {new Date( + data.start_date * 1000, + ).getFullYear()} + ) + </span> + )} + </H2> + {loggedUser && ( + <EditButton + key={String(params.slug)} + slug={String(params.slug)} + content_type="manga" + className="hidden lg:flex" + /> + )} + </div> + <P className="text-sm text-muted-foreground"> + {data.title_original} + </P> + </div> + <div className="flex flex-col gap-2"> + {data.score > 0 && ( + <div className="flex items-start gap-2"> + <div className="font-display text-4xl font-bold"> + {data.score} + </div> + + <MaterialSymbolsStarRounded className="text-2xl" /> + </div> + )} + {loggedUser && ( + <EditButton + key={String(params.slug)} + slug={String(params.slug)} + content_type="manga" + className="flex lg:hidden" + /> + )} + </div> + </div> + {data.genres.length > 0 && ( + <div className="flex flex-wrap gap-1"> + {data.genres.map((genre) => ( + <span key={genre.slug} className="text-sm"> + <Link + className="rounded px-1 underline decoration-primary decoration-dashed transition-colors duration-100 hover:bg-primary hover:text-primary-foreground" + href={`/manga?genres=${genre.slug}`} + > + {genre.name_ua} + </Link> + </span> + ))} + </div> + )} + </div> + ); +}; + +export default Title; diff --git a/features/modals/anime-filters-modal.tsx b/features/modals/anime-filters-modal.tsx index 8564386e..09848aa9 100644 --- a/features/modals/anime-filters-modal.tsx +++ b/features/modals/anime-filters-modal.tsx @@ -11,14 +11,14 @@ import { } from '@/components/ui/drawer'; import { Separator } from '@/components/ui/separator'; -import Filters from '@/features/filters/anime-filters.component'; +import AnimeFilters from '@/features/filters/anime-filters.component'; interface Props { - type: 'anime' | 'watchlist'; + sort_type: 'anime' | 'watch'; children?: ReactNode; } -const Component = ({ type, children }: Props) => { +const AnimeFiltersModal = ({ sort_type, children }: Props) => { return ( <Drawer> <DrawerTrigger asChild> @@ -28,15 +28,19 @@ const Component = ({ type, children }: Props) => { </Button> )} </DrawerTrigger> - <DrawerContent className="h-[90dvh] p-4 pt-0"> - <DrawerHeader className="px-0 text-left"> + <DrawerContent className="h-[90dvh]"> + <DrawerHeader> <DrawerTitle>Фільтри</DrawerTitle> </DrawerHeader> - <Separator className="-mx-6 w-auto" /> - <Filters type={type} className="-mx-6 px-6" /> + <Separator className="w-auto" /> + <AnimeFilters + content_type="anime" + className="px-6" + sort_type={sort_type} + /> </DrawerContent> </Drawer> ); }; -export default Component; +export default AnimeFiltersModal; diff --git a/features/modals/auth-modal/forgot-password-form.tsx b/features/modals/auth-modal/forgot-password-form.tsx index 3622eecc..87dd7e3c 100644 --- a/features/modals/auth-modal/forgot-password-form.tsx +++ b/features/modals/auth-modal/forgot-password-form.tsx @@ -90,9 +90,8 @@ const Component = () => { onClick={() => openModal({ content: <AuthModal type="login" />, - className: 'max-w-3xl', + className: 'max-w-3xl p-0', forceModal: true, - containerClassName: 'p-0', }) } className="w-full" diff --git a/features/modals/auth-modal/login-form.tsx b/features/modals/auth-modal/login-form.tsx index d8355ea1..2d004878 100644 --- a/features/modals/auth-modal/login-form.tsx +++ b/features/modals/auth-modal/login-form.tsx @@ -93,8 +93,7 @@ const Component = () => { content: ( <AuthModal type="forgotPassword" /> ), - className: 'max-w-3xl', - containerClassName: 'p-0', + className: 'max-w-3xl p-0', forceModal: true, }) } @@ -125,8 +124,7 @@ const Component = () => { onClick={() => openModal({ content: <AuthModal type="signup" />, - className: 'max-w-3xl', - containerClassName: 'p-0', + className: 'max-w-3xl p-0', forceModal: true, }) } diff --git a/features/modals/auth-modal/signup-form.tsx b/features/modals/auth-modal/signup-form.tsx index 4f08f2c6..74982d39 100644 --- a/features/modals/auth-modal/signup-form.tsx +++ b/features/modals/auth-modal/signup-form.tsx @@ -144,8 +144,7 @@ const Component = () => { onClick={() => openModal({ content: <AuthModal type="login" />, - className: 'max-w-3xl', - containerClassName: 'p-0', + className: 'max-w-3xl p-0', forceModal: true, }) } diff --git a/features/modals/crop-editor-modal.tsx b/features/modals/crop-editor-modal.tsx index 18b5b944..23a42d80 100644 --- a/features/modals/crop-editor-modal.tsx +++ b/features/modals/crop-editor-modal.tsx @@ -4,7 +4,6 @@ import { useQueryClient } from '@tanstack/react-query'; import clsx from 'clsx'; import { useParams, useRouter } from 'next/navigation'; import { useSnackbar } from 'notistack'; -import * as React from 'react'; import { useRef, useState } from 'react'; import AvatarEditor from 'react-avatar-editor'; import MaterialSymbolsZoomInRounded from '~icons/material-symbols/zoom-in-rounded'; @@ -127,7 +126,7 @@ const Component = ({ file, type }: Props) => { await uploadFile(file); await queryClient.invalidateQueries({ - queryKey: ['loggedUser'], + queryKey: ['logged-user'], }); await queryClient.invalidateQueries({ queryKey: ['user', params.username], diff --git a/features/modals/edit-filters-modal.tsx b/features/modals/edit-filters-modal.tsx index b8a288fe..f2b5e657 100644 --- a/features/modals/edit-filters-modal.tsx +++ b/features/modals/edit-filters-modal.tsx @@ -27,12 +27,12 @@ const Component = ({ children }: Props) => { </Button> )} </DrawerTrigger> - <DrawerContent className="h-[90dvh] p-4 pt-0"> - <DrawerHeader className="px-0 text-left"> + <DrawerContent className="h-[90dvh]"> + <DrawerHeader> <DrawerTitle>Фільтри</DrawerTitle> </DrawerHeader> - <Separator className="-mx-6 w-auto" /> - <Filters className="-mx-6 px-6" /> + <Separator /> + <Filters className="px-6" /> </DrawerContent> </Drawer> ); diff --git a/features/modals/edit-top-stats-modal/edit-top-item.tsx b/features/modals/edit-top-stats-modal/edit-top-item.tsx index 22dc938e..c2ed2cde 100644 --- a/features/modals/edit-top-stats-modal/edit-top-item.tsx +++ b/features/modals/edit-top-stats-modal/edit-top-item.tsx @@ -1,5 +1,4 @@ import Link from 'next/link'; -import * as React from 'react'; import MaterialSymbolsKidStar from '~icons/material-symbols/kid-star'; import Small from '@/components/typography/small'; @@ -20,7 +19,7 @@ const Component = ({ user, rank, accepted, denied, closed }: Props) => { return ( <div className={cn( - 'relative flex min-w-0 flex-1 items-center gap-4 rounded-md p-4', + 'relative flex min-w-0 flex-1 items-center gap-4 rounded-md px-6 py-4', )} > <Label className="text-muted-foreground">{rank}</Label> diff --git a/features/modals/edit-top-stats-modal/edit-top-stats-modal.tsx b/features/modals/edit-top-stats-modal/edit-top-stats-modal.tsx index 4857a8c7..340caeab 100644 --- a/features/modals/edit-top-stats-modal/edit-top-stats-modal.tsx +++ b/features/modals/edit-top-stats-modal/edit-top-stats-modal.tsx @@ -15,32 +15,29 @@ const Component = () => { } return ( - <> - <hr className="-mx-6 mt-4 h-px w-auto bg-border" /> - <div className="-mx-6 h-full w-auto flex-1 overflow-y-scroll"> - {list.map((stat, index) => { - return ( - <EditTopItem - key={stat.user.reference} - user={stat.user} - rank={index + 1} - accepted={stat.accepted} - closed={stat.closed} - denied={stat.denied} - /> - ); - })} - {hasNextPage && ( - <div className="px-4"> - <LoadMoreButton - isFetchingNextPage={isFetchingNextPage} - fetchNextPage={fetchNextPage} - ref={ref} - /> - </div> - )} - </div> - </> + <div className="h-full w-auto flex-1 overflow-y-scroll"> + {list.map((stat, index) => { + return ( + <EditTopItem + key={stat.user.reference} + user={stat.user} + rank={index + 1} + accepted={stat.accepted} + closed={stat.closed} + denied={stat.denied} + /> + ); + })} + {hasNextPage && ( + <div className="px-6"> + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + </div> + )} + </div> ); }; diff --git a/features/modals/editlist-modal/edit-card.tsx b/features/modals/editlist-modal/edit-card.tsx index ca28f7f8..a0b96a97 100644 --- a/features/modals/editlist-modal/edit-card.tsx +++ b/features/modals/editlist-modal/edit-card.tsx @@ -27,7 +27,7 @@ const Component = ({ edit, href, ...props }: Props) => { {...props} href={href} className={clsx( - 'flex w-full items-center gap-4 px-8 py-4', + 'flex w-full items-center gap-4 px-6 py-4', edit.author ? 'hover:cursor-pointer hover:bg-muted' : 'pointer-events-none', diff --git a/features/modals/editlist-modal/editlist-modal.tsx b/features/modals/editlist-modal/editlist-modal.tsx index efb594c7..3c89a8b7 100644 --- a/features/modals/editlist-modal/editlist-modal.tsx +++ b/features/modals/editlist-modal/editlist-modal.tsx @@ -1,17 +1,18 @@ 'use client'; -import clsx from 'clsx'; import Link from 'next/link'; import { useEffect } from 'react'; import { useInView } from 'react-intersection-observer'; import LoadMoreButton from '@/components/load-more-button'; import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; import EditCard from '@/features/modals/editlist-modal/edit-card'; import getEditList from '@/services/api/edit/getEditList'; import useInfiniteList from '@/services/hooks/use-infinite-list'; +import { cn } from '@/utils/utils'; interface Props { content_type: API.ContentType; @@ -45,7 +46,7 @@ const Component = ({ content_type, slug }: Props) => { return ( <> - <div className={clsx('relative py-6')}> + <div className={cn('relative py-4 px-6')}> <Button variant="secondary" className="w-full" asChild> <Link href={`/edit/new?slug=${slug}&content_type=${content_type}`} @@ -54,9 +55,9 @@ const Component = ({ content_type, slug }: Props) => { </Link> </Button> </div> - <hr className="-mx-6 h-px w-auto bg-border" /> + <Separator /> {list!.length > 0 && ( - <div className="-mx-6 h-full w-auto flex-1 overflow-y-scroll"> + <div className="h-full w-auto flex-1 overflow-y-scroll"> {list!.map((edit) => ( <EditCard href={`/edit/` + edit.edit_id} @@ -65,7 +66,7 @@ const Component = ({ content_type, slug }: Props) => { /> ))} {hasNextPage && ( - <div className="px-8 py-4"> + <div className="px-6 py-4"> <LoadMoreButton isFetchingNextPage={isFetchingNextPage} fetchNextPage={fetchNextPage} diff --git a/features/modals/followlist-modal/follow-user-item.tsx b/features/modals/followlist-modal/follow-user-item.tsx index 34d4c338..0067c552 100644 --- a/features/modals/followlist-modal/follow-user-item.tsx +++ b/features/modals/followlist-modal/follow-user-item.tsx @@ -2,7 +2,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; -import * as React from 'react'; import { FC } from 'react'; import MaterialSymbolsShieldRounded from '~icons/material-symbols/shield-rounded'; @@ -21,7 +20,7 @@ const FollowUserItem: FC<Props> = ({ user }) => { const queryClient = useQueryClient(); const loggedUser: API.User | undefined = queryClient.getQueryData([ - 'loggedUser', + 'logged-user', ]); const { mutate: mutateFollow } = useMutation({ diff --git a/features/modals/followlist-modal/followlist-modal.tsx b/features/modals/followlist-modal/followlist-modal.tsx index 7538a620..b8eb0c03 100644 --- a/features/modals/followlist-modal/followlist-modal.tsx +++ b/features/modals/followlist-modal/followlist-modal.tsx @@ -36,23 +36,20 @@ const Component = ({ type }: Props) => { } return ( - <> - <hr className="-mx-6 mt-4 h-px w-auto bg-border" /> - <div className="-mx-6 h-full w-auto flex-1 overflow-y-scroll"> - {list.map((user) => { - return <FollowUserItem key={user.reference} user={user} />; - })} - {hasNextPage && ( - <div className="px-4"> - <LoadMoreButton - isFetchingNextPage={isFetchingNextPage} - fetchNextPage={fetchNextPage} - ref={ref} - /> - </div> - )} - </div> - </> + <div className="h-full w-auto flex-1 overflow-y-scroll"> + {list.map((user) => { + return <FollowUserItem key={user.reference} user={user} />; + })} + {hasNextPage && ( + <div className="px-6"> + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + </div> + )} + </div> ); }; diff --git a/features/modals/read-edit-modal.tsx b/features/modals/read-edit-modal.tsx new file mode 100644 index 00000000..32568681 --- /dev/null +++ b/features/modals/read-edit-modal.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { createElement, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import FormInput from '@/components/form/form-input'; +import FormTextarea from '@/components/form/form-textarea'; +import { Button } from '@/components/ui/button'; +import { Form } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectGroup, + SelectIcon, + SelectItem, + SelectList, + SelectTrigger, +} from '@/components/ui/select'; + +import useAddRead from '@/services/hooks/read/use-add-read'; +import useDeleteRead from '@/services/hooks/read/use-delete-read'; +import useRead from '@/services/hooks/read/use-read'; +import { useModalContext } from '@/services/providers/modal-provider'; +import { READ_STATUS } from '@/utils/constants'; +import { z } from '@/utils/zod'; + +const formSchema = z.object({ + score: z.coerce.number().min(0).max(10).nullable().optional(), + volumes: z.coerce.number().min(0).nullable().optional(), + chapters: z.coerce.number().min(0).nullable().optional(), + rereads: z.coerce.number().min(0).nullable().optional(), + note: z.string().nullable().optional(), +}); + +interface Props { + slug: string; + content_type: 'novel' | 'manga'; +} + +const Component = ({ slug, content_type }: Props) => { + const { closeModal } = useModalContext(); + const { data: read } = useRead({ slug, content_type }); + + const { mutate: addRead, isPending: addToListLoading } = useAddRead(); + + const { mutate: deleteRead, isPending: deleteFromListLoading } = + useDeleteRead(); + + const [selectedStatus, setSelectedStatus] = useState< + API.ReadStatus | undefined + >(read?.status); + + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + values: read, + }); + + const onDelete = async () => { + deleteRead({ params: { slug, content_type } }); + closeModal(); + }; + + useEffect(() => { + if (read?.status) { + setSelectedStatus(read.status); + } + }, [read]); + + if (!read) return null; + + return ( + <Form {...form}> + <form + onSubmit={(e) => e.preventDefault()} + className="flex flex-col gap-6" + > + <div className="flex w-full flex-col gap-6"> + <div className="flex w-full flex-col gap-2"> + <Label>Список</Label> + <Select + value={selectedStatus && [selectedStatus]} + onValueChange={(value) => { + setSelectedStatus(value[0] as API.ReadStatus); + }} + > + <SelectTrigger> + <div className="flex items-center gap-2"> + {selectedStatus && + createElement( + READ_STATUS[selectedStatus].icon!, + )} + {(selectedStatus && + READ_STATUS[selectedStatus].title_ua) || + 'Виберіть список'} + </div> + <SelectIcon /> + </SelectTrigger> + <SelectContent> + <SelectList> + <SelectGroup> + {( + Object.keys( + READ_STATUS, + ) as API.ReadStatus[] + ).map((status) => ( + <SelectItem + value={status} + key={status} + > + {READ_STATUS[status].title_ua} + </SelectItem> + ))} + </SelectGroup> + </SelectList> + </SelectContent> + </Select> + </div> + <div className="flex w-full gap-8"> + <FormInput + name="volumes" + label="Томи" + placeholder="Введіть к-сть прочитаних томів" + type="number" + className="flex-1" + /> + <FormInput + name="chapters" + label="Розділи" + placeholder="Введіть к-сть прочитаних розділів" + type="number" + className="flex-1" + /> + </div> + <div className="flex w-full gap-8"> + <FormInput + name="score" + label="Оцінка" + placeholder="Введіть оцінку" + type="number" + className="flex-1" + /> + <FormInput + name="rereads" + label="Повторні перегляди" + placeholder="Введіть к-сть повторних читань" + type="number" + className="flex-1" + /> + </div> + + <FormTextarea + name="note" + label="Нотатки" + placeholder="Залиште нотатку до аніме" + /> + </div> + <div className="grid w-full grid-cols-2 gap-8"> + <Button + type="button" + variant="destructive" + onClick={onDelete} + disabled={addToListLoading || deleteFromListLoading} + > + {deleteFromListLoading && ( + <span className="loading loading-spinner"></span> + )} + Видалити + </Button> + <Button + variant="accent" + onClick={form.handleSubmit((data) => + addRead({ + params: { + content_type, + slug, + status: selectedStatus!, + ...data, + }, + }), + )} + type="submit" + disabled={addToListLoading || deleteFromListLoading} + > + {addToListLoading && ( + <span className="loading loading-spinner"></span> + )} + Зберегти + </Button> + </div> + </form> + </Form> + ); +}; + +export default Component; diff --git a/features/modals/read-filters-modal.tsx b/features/modals/read-filters-modal.tsx new file mode 100644 index 00000000..be36b2e5 --- /dev/null +++ b/features/modals/read-filters-modal.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from 'react'; +import AntDesignFilterFilled from '~icons/ant-design/filter-filled'; + +import { Button } from '@/components/ui/button'; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '@/components/ui/drawer'; +import { Separator } from '@/components/ui/separator'; + +import ReadFilters from '@/features/filters/read-filters.component'; + +interface Props { + content_type: API.ContentType; + sort_type: 'manga' | 'novel' | 'read'; + children?: ReactNode; +} + +const AnimeFiltersModal = ({ sort_type, content_type, children }: Props) => { + return ( + <Drawer> + <DrawerTrigger asChild> + {children || ( + <Button variant="outline" size="icon"> + <AntDesignFilterFilled /> + </Button> + )} + </DrawerTrigger> + <DrawerContent className="h-[90dvh]"> + <DrawerHeader> + <DrawerTitle>Фільтри</DrawerTitle> + </DrawerHeader> + <Separator /> + <ReadFilters + content_type={content_type} + sort_type={sort_type} + className="px-6" + /> + </DrawerContent> + </Drawer> + ); +}; + +export default AnimeFiltersModal; diff --git a/features/modals/schedule-filters-modal.tsx b/features/modals/schedule-filters-modal.tsx index 64417e42..089954e7 100644 --- a/features/modals/schedule-filters-modal.tsx +++ b/features/modals/schedule-filters-modal.tsx @@ -27,12 +27,12 @@ const Component = ({ children }: Props) => { </Button> )} </DrawerTrigger> - <DrawerContent className="h-[90dvh] p-4 pt-0"> - <DrawerHeader className="px-0 text-left"> + <DrawerContent className="h-[90dvh]"> + <DrawerHeader> <DrawerTitle>Фільтри</DrawerTitle> </DrawerHeader> - <Separator className="-mx-6 w-auto" /> - <Filters className="-mx-6 p-6" /> + <Separator /> + <Filters className="px-6" /> </DrawerContent> </Drawer> ); diff --git a/features/modals/search-modal/anime-search-list.tsx b/features/modals/search-modal/anime-search-list.tsx index f38ed063..36159f48 100644 --- a/features/modals/search-modal/anime-search-list.tsx +++ b/features/modals/search-modal/anime-search-list.tsx @@ -10,8 +10,8 @@ import { import SearchPlaceholders from '@/features/modals/search-modal/search-placeholders'; -import AnimeCard from './anime-card'; -import useAnimeSearchList from './useAnimeSearchList'; +import AnimeCard from './cards/anime-card'; +import useAnimeSearchList from './hooks/useAnimeSearchList'; interface Props { onDismiss: (anime: API.Anime) => void; diff --git a/features/modals/search-modal/anime-card.tsx b/features/modals/search-modal/cards/anime-card.tsx similarity index 87% rename from features/modals/search-modal/anime-card.tsx rename to features/modals/search-modal/cards/anime-card.tsx index 366230fc..51fb0379 100644 --- a/features/modals/search-modal/anime-card.tsx +++ b/features/modals/search-modal/cards/anime-card.tsx @@ -8,9 +8,9 @@ import ContentCard from '@/components/content-card/content-card'; import P from '@/components/typography/p'; import { Badge } from '@/components/ui/badge'; -import { MEDIA_TYPE, RELEASE_STATUS } from '@/utils/constants'; +import { ANIME_MEDIA_TYPE, RELEASE_STATUS } from '@/utils/constants'; -import { Label } from '../../../components/ui/label'; +import { Label } from '../../../../components/ui/label'; interface Props { anime: API.Anime; @@ -28,7 +28,7 @@ const AnimeCard = ({ anime, onClick, type }: Props) => { className="flex w-full items-start gap-4 text-left" > <div className="w-12 sm:w-16"> - <ContentCard poster={anime.poster} /> + <ContentCard image={anime.image} /> </div> <div className="flex w-full flex-1 flex-col gap-2"> <div className="flex items-center gap-2"> @@ -49,7 +49,10 @@ const AnimeCard = ({ anime, onClick, type }: Props) => { <> <div className="size-1 rounded-full bg-muted-foreground" /> <Label className="text-xs text-muted-foreground"> - {MEDIA_TYPE[anime.media_type].title_ua} + { + ANIME_MEDIA_TYPE[anime.media_type] + .title_ua + } </Label> </> )} diff --git a/features/modals/search-modal/character-card.tsx b/features/modals/search-modal/cards/character-card.tsx similarity index 95% rename from features/modals/search-modal/character-card.tsx rename to features/modals/search-modal/cards/character-card.tsx index 39f1e8fd..235db0eb 100644 --- a/features/modals/search-modal/character-card.tsx +++ b/features/modals/search-modal/cards/character-card.tsx @@ -22,7 +22,7 @@ const CharacterCard = ({ character, onClick, type }: Props) => { className="flex w-full gap-4 text-left" > <div className="w-12 sm:w-16"> - <ContentCard poster={character.image} /> + <ContentCard image={character.image} /> </div> <div className="flex w-full flex-1 flex-col gap-2"> <div className="flex items-center gap-2"> diff --git a/features/modals/search-modal/cards/manga-card.tsx b/features/modals/search-modal/cards/manga-card.tsx new file mode 100644 index 00000000..3281f216 --- /dev/null +++ b/features/modals/search-modal/cards/manga-card.tsx @@ -0,0 +1,80 @@ +'use client'; + +import Link from 'next/link'; +import * as React from 'react'; +import MaterialSymbolsStarRounded from '~icons/material-symbols/star-rounded'; + +import ContentCard from '@/components/content-card/content-card'; +import P from '@/components/typography/p'; +import { Badge } from '@/components/ui/badge'; + +import { MANGA_MEDIA_TYPE, RELEASE_STATUS } from '@/utils/constants'; + +import { Label } from '../../../../components/ui/label'; + +interface Props { + manga: API.Manga; + onClick?: React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>; + type?: 'link' | 'button'; +} + +const MangaCard = ({ manga, onClick, type }: Props) => { + const Comp = type === 'button' ? 'button' : Link; + + return ( + <Comp + href={'/manga/' + manga.slug} + onClick={onClick} + className="flex w-full items-start gap-4 text-left" + > + <div className="w-12 sm:w-16"> + <ContentCard image={manga.image} /> + </div> + <div className="flex w-full flex-1 flex-col gap-2"> + <div className="flex items-center gap-2"> + <Label className="line-clamp-2 font-bold"> + {manga.title}{' '} + <Label className="text-muted-foreground"> + / {manga.title_original} + </Label> + </Label> + </div> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Label className="text-xs text-muted-foreground"> + {manga.year} + </Label> + + {manga.media_type && ( + <> + <div className="size-1 rounded-full bg-muted-foreground" /> + <Label className="text-xs text-muted-foreground"> + { + MANGA_MEDIA_TYPE[manga.media_type] + .title_ua + } + </Label> + </> + )} + <div className="size-1 rounded-full bg-muted-foreground" /> + <Badge + className="text-xs" + variant="status" + bgColor={RELEASE_STATUS[manga.status].color} + > + {RELEASE_STATUS[manga.status].title_ua} + </Badge> + </div> + </div> + </div> + {manga.score > 0 && ( + <div className="flex items-center gap-1 text-sm"> + <P className="font-bold leading-normal">{manga.score}</P> + <MaterialSymbolsStarRounded className="hidden sm:block" /> + </div> + )} + </Comp> + ); +}; + +export default MangaCard; diff --git a/features/modals/search-modal/cards/novel-card.tsx b/features/modals/search-modal/cards/novel-card.tsx new file mode 100644 index 00000000..e48697da --- /dev/null +++ b/features/modals/search-modal/cards/novel-card.tsx @@ -0,0 +1,80 @@ +'use client'; + +import Link from 'next/link'; +import * as React from 'react'; +import MaterialSymbolsStarRounded from '~icons/material-symbols/star-rounded'; + +import ContentCard from '@/components/content-card/content-card'; +import P from '@/components/typography/p'; +import { Badge } from '@/components/ui/badge'; + +import { NOVEL_MEDIA_TYPE, RELEASE_STATUS } from '@/utils/constants'; + +import { Label } from '../../../../components/ui/label'; + +interface Props { + novel: API.Novel; + onClick?: React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>; + type?: 'link' | 'button'; +} + +const NovelCard = ({ novel, onClick, type }: Props) => { + const Comp = type === 'button' ? 'button' : Link; + + return ( + <Comp + href={'/novel/' + novel.slug} + onClick={onClick} + className="flex w-full items-start gap-4 text-left" + > + <div className="w-12 sm:w-16"> + <ContentCard image={novel.image} /> + </div> + <div className="flex w-full flex-1 flex-col gap-2"> + <div className="flex items-center gap-2"> + <Label className="line-clamp-2 font-bold"> + {novel.title}{' '} + <Label className="text-muted-foreground"> + / {novel.title_original} + </Label> + </Label> + </div> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Label className="text-xs text-muted-foreground"> + {novel.year} + </Label> + + {novel.media_type && ( + <> + <div className="size-1 rounded-full bg-muted-foreground" /> + <Label className="text-xs text-muted-foreground"> + { + NOVEL_MEDIA_TYPE[novel.media_type] + .title_ua + } + </Label> + </> + )} + <div className="size-1 rounded-full bg-muted-foreground" /> + <Badge + className="text-xs" + variant="status" + bgColor={RELEASE_STATUS[novel.status].color} + > + {RELEASE_STATUS[novel.status].title_ua} + </Badge> + </div> + </div> + </div> + {novel.score > 0 && ( + <div className="flex items-center gap-1 text-sm"> + <P className="font-bold leading-normal">{novel.score}</P> + <MaterialSymbolsStarRounded className="hidden sm:block" /> + </div> + )} + </Comp> + ); +}; + +export default NovelCard; diff --git a/features/modals/search-modal/person-card.tsx b/features/modals/search-modal/cards/person-card.tsx similarity index 95% rename from features/modals/search-modal/person-card.tsx rename to features/modals/search-modal/cards/person-card.tsx index 81ab724b..38605e5f 100644 --- a/features/modals/search-modal/person-card.tsx +++ b/features/modals/search-modal/cards/person-card.tsx @@ -22,7 +22,7 @@ const PersonCard = ({ person, onClick, type }: Props) => { className="flex w-full gap-4 text-left" > <div className="w-12 sm:w-16"> - <ContentCard poster={person.image} /> + <ContentCard image={person.image} /> </div> <div className="flex w-full flex-1 flex-col gap-2"> <div className="flex items-center gap-2"> diff --git a/features/modals/search-modal/user-card.tsx b/features/modals/search-modal/cards/user-card.tsx similarity index 96% rename from features/modals/search-modal/user-card.tsx rename to features/modals/search-modal/cards/user-card.tsx index 1a050e4b..c4878301 100644 --- a/features/modals/search-modal/user-card.tsx +++ b/features/modals/search-modal/cards/user-card.tsx @@ -26,7 +26,7 @@ const UserCard = ({ user, onClick, type }: Props) => { className="flex w-full gap-4 text-left" > <div className="w-12 sm:w-16"> - <ContentCard poster={user.avatar} containerRatio={1} /> + <ContentCard image={user.avatar} containerRatio={1} /> </div> <div className="flex w-full flex-1 flex-col gap-2"> <div className="flex items-center gap-2"> diff --git a/features/modals/search-modal/character-search-list.tsx b/features/modals/search-modal/character-search-list.tsx index d01ac91d..c5d8ec61 100644 --- a/features/modals/search-modal/character-search-list.tsx +++ b/features/modals/search-modal/character-search-list.tsx @@ -8,10 +8,10 @@ import { CommandList, } from '@/components/ui/command'; +import useCharacterSearchList from '@/features/modals/search-modal/hooks/useCharacterSearchList'; import SearchPlaceholders from '@/features/modals/search-modal/search-placeholders'; -import useCharacterSearchList from '@/features/modals/search-modal/useCharacterSearchList'; -import CharacterCard from './character-card'; +import CharacterCard from './cards/character-card'; interface Props { onDismiss: (character: API.Character) => void; diff --git a/features/modals/search-modal/useAnimeSearchList.ts b/features/modals/search-modal/hooks/useAnimeSearchList.ts similarity index 85% rename from features/modals/search-modal/useAnimeSearchList.ts rename to features/modals/search-modal/hooks/useAnimeSearchList.ts index f761a31f..4e11a45c 100644 --- a/features/modals/search-modal/useAnimeSearchList.ts +++ b/features/modals/search-modal/hooks/useAnimeSearchList.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import getAnimeCatalog from '@/services/api/anime/getAnimeCatalog'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnimeList } from '@/utils/anime-adapter'; +import { convertTitleList } from '@/utils/title-adapter'; interface Props { value?: string; @@ -23,9 +23,9 @@ const useAnimeSearchList = ({ value }: Props) => { enabled: value !== undefined && value.length >= 3, select: (data) => ({ ...data, - list: convertAnimeList<API.Anime>({ + list: convertTitleList<API.Anime>({ titleLanguage: titleLanguage!, - anime: data.list, + data: data.list, }), }), }); diff --git a/features/modals/search-modal/useCharacterSearchList.ts b/features/modals/search-modal/hooks/useCharacterSearchList.ts similarity index 100% rename from features/modals/search-modal/useCharacterSearchList.ts rename to features/modals/search-modal/hooks/useCharacterSearchList.ts diff --git a/features/modals/search-modal/hooks/useMangaSearchList.ts b/features/modals/search-modal/hooks/useMangaSearchList.ts new file mode 100644 index 00000000..0eb176f3 --- /dev/null +++ b/features/modals/search-modal/hooks/useMangaSearchList.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; + +import getMangaCatalog from '@/services/api/manga/getMangaCatalog'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import { convertTitleList } from '@/utils/title-adapter'; + +interface Props { + value?: string; +} + +const useMangaSearchList = ({ value }: Props) => { + const { titleLanguage } = useSettingsContext(); + + return useQuery<API.WithPagination<API.Manga>, Error>({ + queryKey: ['manga-search-list', value], + queryFn: () => + getMangaCatalog({ + params: { + query: value, + }, + size: 30, + }), + enabled: value !== undefined && value.length >= 3, + select: (data) => ({ + ...data, + list: convertTitleList<API.Manga>({ + titleLanguage: titleLanguage!, + data: data.list, + }), + }), + }); +}; + +export default useMangaSearchList; diff --git a/features/modals/search-modal/hooks/useNovelSearchList.ts b/features/modals/search-modal/hooks/useNovelSearchList.ts new file mode 100644 index 00000000..2b6f6a89 --- /dev/null +++ b/features/modals/search-modal/hooks/useNovelSearchList.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; + +import getNovelCatalog from '@/services/api/novel/getNovelCatalog'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import { convertTitleList } from '@/utils/title-adapter'; + +interface Props { + value?: string; +} + +const useNovelSearchList = ({ value }: Props) => { + const { titleLanguage } = useSettingsContext(); + + return useQuery<API.WithPagination<API.Novel>, Error>({ + queryKey: ['novel-search-list', value], + queryFn: () => + getNovelCatalog({ + params: { + query: value, + }, + size: 30, + }), + enabled: value !== undefined && value.length >= 3, + select: (data) => ({ + ...data, + list: convertTitleList<API.Novel>({ + titleLanguage: titleLanguage!, + data: data.list, + }), + }), + }); +}; + +export default useNovelSearchList; diff --git a/features/modals/search-modal/usePersonSearchList.ts b/features/modals/search-modal/hooks/usePersonSearchList.ts similarity index 100% rename from features/modals/search-modal/usePersonSearchList.ts rename to features/modals/search-modal/hooks/usePersonSearchList.ts diff --git a/features/modals/search-modal/useSearchModal.ts b/features/modals/search-modal/hooks/useSearchModal.ts similarity index 65% rename from features/modals/search-modal/useSearchModal.ts rename to features/modals/search-modal/hooks/useSearchModal.ts index 1d6d8623..5a88efa7 100644 --- a/features/modals/search-modal/useSearchModal.ts +++ b/features/modals/search-modal/hooks/useSearchModal.ts @@ -1,18 +1,34 @@ +import { usePathname } from 'next/navigation'; import { Dispatch, SetStateAction, useEffect } from 'react'; +import { CONTENT_TYPE_LINKS } from '@/utils/constants'; + interface Props { + open: boolean; onClick?: (anime: API.Anime) => void; setOpen: Dispatch<SetStateAction<boolean>>; setSearchType?: Dispatch<SetStateAction<API.ContentType | 'user'>>; content_type?: API.ContentType | 'user'; } +const ALLOWED_SEARCH_TYPES: (API.ContentType | 'user')[] = [ + 'anime', + 'manga', + 'novel', + 'character', + 'person', + 'user', +]; + const useSearchModal = ({ onClick, + open, setOpen, setSearchType, content_type, }: Props) => { + const pathname = usePathname(); + useEffect(() => { function handleKeyDown(e: KeyboardEvent) { const _focused = document.activeElement; @@ -42,6 +58,18 @@ const useSearchModal = ({ setSearchType(content_type); } }, [content_type]); + + useEffect(() => { + if (open) { + const currentPageContentType = ALLOWED_SEARCH_TYPES.find((ct) => + pathname.startsWith(CONTENT_TYPE_LINKS[ct as API.ContentType]), + ); + + if (currentPageContentType) { + setSearchType!(currentPageContentType as API.ContentType); + } + } + }, [open]); }; export default useSearchModal; diff --git a/features/modals/search-modal/useUserSearchList.ts b/features/modals/search-modal/hooks/useUserSearchList.ts similarity index 100% rename from features/modals/search-modal/useUserSearchList.ts rename to features/modals/search-modal/hooks/useUserSearchList.ts diff --git a/features/modals/search-modal/manga-search-list.tsx b/features/modals/search-modal/manga-search-list.tsx new file mode 100644 index 00000000..cfba4687 --- /dev/null +++ b/features/modals/search-modal/manga-search-list.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { ReactNode } from 'react'; + +import { + CommandGroup, + CommandItem, + CommandList, +} from '@/components/ui/command'; + +import SearchPlaceholders from '@/features/modals/search-modal/search-placeholders'; + +import MangaCard from './cards/manga-card'; +import useMangaSearchList from './hooks/useMangaSearchList'; + +interface Props { + onDismiss: (manga: API.Manga) => void; + type?: 'link' | 'button'; + children?: ReactNode; + value?: string; +} + +const MangaSearchList = ({ onDismiss, type, value }: Props) => { + const { data, isFetching, isRefetching } = useMangaSearchList({ value }); + + return ( + <CommandList className="max-h-none"> + <SearchPlaceholders + data={data} + isFetching={isFetching} + isRefetching={isRefetching} + /> + {data && data.list.length > 0 && ( + <CommandGroup> + {data.list.map((manga) => ( + <CommandItem key={manga.slug} value={manga.slug}> + <MangaCard + onClick={() => onDismiss(manga)} + manga={manga} + type={type} + /> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + ); +}; + +export default MangaSearchList; diff --git a/features/modals/search-modal/novel-search-list.tsx b/features/modals/search-modal/novel-search-list.tsx new file mode 100644 index 00000000..815ef655 --- /dev/null +++ b/features/modals/search-modal/novel-search-list.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { ReactNode } from 'react'; + +import { + CommandGroup, + CommandItem, + CommandList, +} from '@/components/ui/command'; + +import SearchPlaceholders from '@/features/modals/search-modal/search-placeholders'; + +import NovelCard from './cards/novel-card'; +import useNovelSearchList from './hooks/useNovelSearchList'; + +interface Props { + onDismiss: (novel: API.Novel) => void; + type?: 'link' | 'button'; + children?: ReactNode; + value?: string; +} + +const NovelSearchList = ({ onDismiss, type, value }: Props) => { + const { data, isFetching, isRefetching } = useNovelSearchList({ value }); + + return ( + <CommandList className="max-h-none"> + <SearchPlaceholders + data={data} + isFetching={isFetching} + isRefetching={isRefetching} + /> + {data && data.list.length > 0 && ( + <CommandGroup> + {data.list.map((novel) => ( + <CommandItem key={novel.slug} value={novel.slug}> + <NovelCard + onClick={() => onDismiss(novel)} + novel={novel} + type={type} + /> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + ); +}; + +export default NovelSearchList; diff --git a/features/modals/search-modal/person-search-list.tsx b/features/modals/search-modal/person-search-list.tsx index 35dafa5c..96120012 100644 --- a/features/modals/search-modal/person-search-list.tsx +++ b/features/modals/search-modal/person-search-list.tsx @@ -8,10 +8,10 @@ import { CommandList, } from '@/components/ui/command'; -import PersonCard from '@/features/modals/search-modal/person-card'; +import PersonCard from '@/features/modals/search-modal/cards/person-card'; import SearchPlaceholders from '@/features/modals/search-modal/search-placeholders'; -import usePersonSearchList from './usePersonSearchList'; +import usePersonSearchList from './hooks/usePersonSearchList'; interface Props { onDismiss: (character: API.Person) => void; diff --git a/features/modals/search-modal/search-modal.tsx b/features/modals/search-modal/search-modal.tsx index 13528b6f..0e79907f 100644 --- a/features/modals/search-modal/search-modal.tsx +++ b/features/modals/search-modal/search-modal.tsx @@ -12,8 +12,10 @@ import UserSearchList from '@/features/modals/search-modal/user-search-list'; import useDebounce from '@/services/hooks/use-debounce'; import AnimeSearchList from './anime-search-list'; +import useSearchModal from './hooks/useSearchModal'; +import MangaSearchList from './manga-search-list'; +import NovelSearchList from './novel-search-list'; import SearchButton from './search-button'; -import useSearchModal from './useSearchModal'; interface Props { onClick?: (content: API.MainContent | API.User) => void; @@ -40,34 +42,34 @@ const SearchModal = ({ onClick, type, content_type, children }: Props) => { onClick && onClick(content); }; - useSearchModal({ setOpen, onClick, content_type, setSearchType }); + useSearchModal({ open, setOpen, onClick, content_type, setSearchType }); return ( <Fragment> <SearchButton setOpen={setOpen}>{children}</SearchButton> <CommandDialog - className="flex max-h-[90dvh] max-w-3xl" + className="mt-16 flex max-h-[80dvh] max-w-3xl" containerClassName="p-0" + overlayClassName="items-start" open={open} onOpenChange={setOpen} shouldFilter={false} > - <div className="flex p-3 dark:bg-secondary/30"> - <SearchToggle - inputRef={inputRef} - disabled={Boolean(content_type)} - setType={setSearchType} - type={searchType} - /> - </div> <CommandInput ref={inputRef} value={searchValue} onValueChange={(value) => setSearchValue(value)} placeholder="Пошук..." autoFocus - containerClassName="dark:bg-secondary/30" - /> + containerClassName="dark:bg-secondary/30 gap-3" + > + <SearchToggle + inputRef={inputRef} + disabled={Boolean(content_type)} + setType={setSearchType} + type={searchType} + /> + </CommandInput> {searchType === 'anime' && ( <AnimeSearchList @@ -77,6 +79,22 @@ const SearchModal = ({ onClick, type, content_type, children }: Props) => { /> )} + {searchType === 'manga' && ( + <MangaSearchList + onDismiss={onDismiss} + value={value} + type={type} + /> + )} + + {searchType === 'novel' && ( + <NovelSearchList + onDismiss={onDismiss} + value={value} + type={type} + /> + )} + {searchType === 'character' && ( <CharacterSearchList onDismiss={onDismiss} diff --git a/features/modals/search-modal/search-toggle.tsx b/features/modals/search-modal/search-toggle.tsx index 19f3c2d8..cf999aec 100644 --- a/features/modals/search-modal/search-toggle.tsx +++ b/features/modals/search-modal/search-toggle.tsx @@ -1,8 +1,25 @@ 'use client'; import * as React from 'react'; +import MaterialSymbolsAccountBox from '~icons/material-symbols/account-box'; +import MaterialSymbolsFace3 from '~icons/material-symbols/face-3'; +import MaterialSymbolsMenuBookRounded from '~icons/material-symbols/menu-book-rounded'; +import MaterialSymbolsPalette from '~icons/material-symbols/palette'; +import MaterialSymbolsPerson from '~icons/material-symbols/person'; +import MaterialSymbolsPlayArrowRounded from '~icons/material-symbols/play-arrow-rounded'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { buttonVariants } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectList, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import { cn } from '@/utils/utils'; interface Props { type?: API.ContentType | 'user'; @@ -12,26 +29,69 @@ interface Props { } const SearchToggle = ({ type, setType, disabled, inputRef }: Props) => { - const handleOnValueChange = (value: API.ContentType) => { - value && setType(value); + const handleOnValueChange = (value: API.ContentType[]) => { + value && setType(value[0]); inputRef.current?.focus(); }; return ( - <ToggleGroup + <Select disabled={disabled} - defaultValue="anime" - type="single" - size="badge" - variant="default" - value={type} + value={type ? [type] : undefined} onValueChange={handleOnValueChange} > - <ToggleGroupItem value="anime">Аніме</ToggleGroupItem> - <ToggleGroupItem value="character">Персонаж</ToggleGroupItem> - <ToggleGroupItem value="person">Людина</ToggleGroupItem> - <ToggleGroupItem value="user">Користувач</ToggleGroupItem> - </ToggleGroup> + <SelectTrigger + className={cn( + buttonVariants({ variant: 'outline', size: 'sm' }), + 'h-8', + )} + asChild + > + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectList> + <SelectGroup> + <SelectItem value="anime"> + <div className="flex items-center gap-2"> + <MaterialSymbolsPlayArrowRounded className="!size-4" />{' '} + Аніме + </div> + </SelectItem> + <SelectItem value="manga"> + <div className="flex items-center gap-2"> + <MaterialSymbolsPalette className="!size-4" />{' '} + Манґа + </div> + </SelectItem> + <SelectItem value="novel"> + <div className="flex items-center gap-2"> + <MaterialSymbolsMenuBookRounded className="!size-4" />{' '} + Ранобе + </div> + </SelectItem> + <SelectItem value="character"> + <div className="flex items-center gap-2"> + <MaterialSymbolsFace3 className="!size-4" />{' '} + Персонаж + </div> + </SelectItem> + <SelectItem value="person"> + <div className="flex items-center gap-2"> + <MaterialSymbolsPerson className="!size-4" />{' '} + Людина + </div> + </SelectItem> + <SelectItem value="user"> + <div className="flex items-center gap-2"> + <MaterialSymbolsAccountBox className="!size-4" />{' '} + Користувач + </div> + </SelectItem> + </SelectGroup> + </SelectList> + </SelectContent> + </Select> ); }; diff --git a/features/modals/search-modal/user-search-list.tsx b/features/modals/search-modal/user-search-list.tsx index 01a1986e..198e5f78 100644 --- a/features/modals/search-modal/user-search-list.tsx +++ b/features/modals/search-modal/user-search-list.tsx @@ -8,10 +8,10 @@ import { CommandList, } from '@/components/ui/command'; +import UserCard from '@/features/modals/search-modal/cards/user-card'; import SearchPlaceholders from '@/features/modals/search-modal/search-placeholders'; -import UserCard from '@/features/modals/search-modal/user-card'; -import useUserSearchList from './useUserSearchList'; +import useUserSearchList from './hooks/useUserSearchList'; interface Props { onDismiss: (character: API.User) => void; diff --git a/features/modals/user-settings-modal/general-form/appearance.tsx b/features/modals/user-settings-modal/general-form/appearance.tsx new file mode 100644 index 00000000..a99f93ed --- /dev/null +++ b/features/modals/user-settings-modal/general-form/appearance.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { enqueueSnackbar } from 'notistack'; +import { ChangeEvent, useRef } from 'react'; +import MaterialSymbolsDeleteForeverRounded from '~icons/material-symbols/delete-forever-rounded'; + +import P from '@/components/typography/p'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import Card from '@/components/ui/card'; +import Image from '@/components/ui/image'; +import { Input } from '@/components/ui/input'; + +import CropEditorModal from '@/features/modals/crop-editor-modal'; + +import deleteUserImage from '@/services/api/settings/deleteUserImage'; +import useSession from '@/services/hooks/auth/use-session'; +import { useModalContext } from '@/services/providers/modal-provider'; + +const Appearance = () => { + const queryClient = useQueryClient(); + const uploadAvatarRef = useRef<HTMLInputElement>(null); + const uploadCoverRef = useRef<HTMLInputElement>(null); + const { openModal } = useModalContext(); + + const { user: loggedUser } = useSession(); + + const mutation = useMutation({ + mutationFn: deleteUserImage, + onSuccess: (data) => { + enqueueSnackbar('Медіафайл успішно видалено.', { + variant: 'success', + }); + queryClient.invalidateQueries(); + }, + }); + + const handleUploadImageSelected = ( + e: ChangeEvent<HTMLInputElement>, + type: 'avatar' | 'cover', + ) => { + if (e.target.files && e.target.files.length > 0) { + const file = Array.from(e.target.files)[0]; + + switch (type) { + case 'avatar': + if (uploadAvatarRef.current) { + uploadAvatarRef.current.value = ''; + } + break; + case 'cover': + if (uploadCoverRef.current) { + uploadCoverRef.current.value = ''; + } + break; + } + + openModal({ + content: <CropEditorModal file={file} type={type} />, + className: '!max-w-lg', + title: 'Редагувати медіафайл', + }); + } + }; + + return ( + <div className="flex flex-col gap-6"> + <div className="relative mb-4 flex h-36 w-full cursor-pointer"> + {loggedUser?.cover && ( + <Button + className="absolute right-2 top-2 z-10" + variant="destructive" + size={'icon-sm'} + onClick={() => + mutation.mutate({ params: { image_type: 'cover' } }) + } + > + <MaterialSymbolsDeleteForeverRounded className="size-4" /> + </Button> + )} + <Card className="flex-1 overflow-hidden bg-secondary/60 p-0 transition-opacity hover:opacity-60"> + {loggedUser?.cover ? ( + <Image + alt="cover" + height={100} + width={300} + className="size-full rounded-md object-cover" + src={loggedUser?.cover} + /> + ) : ( + <div className="flex flex-1 items-center justify-center"> + <P className="text-sm text-muted-foreground"> + Натисність, щоб завантажити обкладинку + </P> + </div> + )} + + <Input + type="file" + id="cover-input" + onChange={(e) => handleUploadImageSelected(e, 'cover')} + ref={uploadCoverRef} + multiple={false} + className="absolute left-0 top-0 size-full cursor-pointer opacity-0" + accept="image/*" + /> + </Card> + <Avatar className="absolute -bottom-4 left-4 size-20 rounded-md transition-opacity hover:opacity-60"> + <AvatarImage src={loggedUser?.avatar} /> + <AvatarFallback>{loggedUser?.username[0]}</AvatarFallback> + <Input + type="file" + id="avatar-input" + onChange={(e) => handleUploadImageSelected(e, 'avatar')} + ref={uploadAvatarRef} + multiple={false} + // eslint-disable-next-line tailwindcss/classnames-order + className="absolute left-0 top-0 size-full opacity-0 cursor-pointer" + accept="image/*" + /> + </Avatar> + </div> + </div> + ); +}; + +export default Appearance; diff --git a/features/modals/user-settings-modal/general-form.tsx b/features/modals/user-settings-modal/general-form/general-form.tsx similarity index 60% rename from features/modals/user-settings-modal/general-form.tsx rename to features/modals/user-settings-modal/general-form/general-form.tsx index 4545ce59..73860227 100644 --- a/features/modals/user-settings-modal/general-form.tsx +++ b/features/modals/user-settings-modal/general-form/general-form.tsx @@ -6,6 +6,7 @@ import { useSnackbar } from 'notistack'; import { useForm } from 'react-hook-form'; import FormTextarea from '@/components/form/form-textarea'; +import H5 from '@/components/typography/h5'; import { Button } from '@/components/ui/button'; import { Form } from '@/components/ui/form'; @@ -14,6 +15,8 @@ import useSession from '@/services/hooks/auth/use-session'; import { useModalContext } from '@/services/providers/modal-provider'; import { z } from '@/utils/zod'; +import Appearance from './appearance'; + const formSchema = z.object({ description: z.string().max(256).nullable(), }); @@ -51,29 +54,34 @@ const Component = () => { }; return ( - <Form {...form}> - <form - onSubmit={form.handleSubmit(handleFormSubmit)} - className="flex flex-col gap-6 p-6" - > - <FormTextarea - name="description" - placeholder="Введіть опис" - label="Опис" - /> - <Button - disabled={mutation.isPending} - variant="default" - type="submit" - className="w-full" + <div className="flex flex-col gap-6 p-6"> + <H5>Вигляд профілю</H5> + <Appearance /> + <H5>Інші налаштування</H5> + <Form {...form}> + <form + onSubmit={form.handleSubmit(handleFormSubmit)} + className="flex flex-col gap-6" > - {mutation.isPending && ( - <span className="loading loading-spinner"></span> - )} - Зберегти - </Button> - </form> - </Form> + <FormTextarea + name="description" + placeholder="Введіть опис" + label="Опис" + /> + <Button + disabled={mutation.isPending} + variant="default" + type="submit" + className="w-full" + > + {mutation.isPending && ( + <span className="loading loading-spinner"></span> + )} + Зберегти + </Button> + </form> + </Form> + </div> ); }; diff --git a/features/modals/user-settings-modal/notifications-form.tsx b/features/modals/user-settings-modal/notifications-form.tsx index 74298c9c..2aa77f51 100644 --- a/features/modals/user-settings-modal/notifications-form.tsx +++ b/features/modals/user-settings-modal/notifications-form.tsx @@ -45,7 +45,7 @@ const Component = () => { useChangeIgnoredNotifications({ onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: ['ignoredNotifications'], + queryKey: ['ignored-notifications'], exact: false, }); closeModal(); diff --git a/features/modals/user-settings-modal/readlist-form/found-list.tsx b/features/modals/user-settings-modal/readlist-form/found-list.tsx new file mode 100644 index 00000000..f40ff2e9 --- /dev/null +++ b/features/modals/user-settings-modal/readlist-form/found-list.tsx @@ -0,0 +1,21 @@ +import P from '@/components/typography/p'; + +interface Props { + readList: Record<string, any>[]; +} + +const Component = ({ readList }: Props) => { + return ( + <div> + <P> + У вашому списку знайдено{' '} + <span className="rounded-sm bg-primary px-1 text-primary-foreground"> + {readList.length} + </span>{' '} + манґи та ранобе, що готові до імпорту + </P> + </div> + ); +}; + +export default Component; diff --git a/features/modals/user-settings-modal/readlist-form/general.tsx b/features/modals/user-settings-modal/readlist-form/general.tsx new file mode 100644 index 00000000..e37cc768 --- /dev/null +++ b/features/modals/user-settings-modal/readlist-form/general.tsx @@ -0,0 +1,134 @@ +'use client'; + +import clsx from 'clsx'; +import Link from 'next/link'; +import { Dispatch, SetStateAction, useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { xml2json } from 'xml-js'; + +import P from '@/components/typography/p'; +import Small from '@/components/typography/small'; +import { Label } from '@/components/ui/label'; + +import FoundList from '@/features/modals/user-settings-modal/readlist-form/found-list'; + +interface Props { + readList: Record<string, any>[]; + setReadList: Dispatch<SetStateAction<Record<string, any>[]>>; +} + +const Component = ({ readList, setReadList }: Props) => { + const onDrop = useCallback(async (acceptedFiles: File[]) => { + const nativeType = (value: string) => { + let nValue = Number(value); + if (!isNaN(nValue)) { + return nValue; + } + let bValue = value.toLowerCase(); + if (bValue === 'true') { + return true; + } else if (bValue === 'false') { + return false; + } + return value; + }; + + const removeJsonTextAttribute = ( + value: string, + parentElement: Record<string, any>, + ) => { + try { + let keyNo = Object.keys(parentElement._parent).length; + let keyName = Object.keys(parentElement._parent)[keyNo - 1]; + + if (keyName === 'my_comments') { + parentElement._parent[keyName] = String(value); + } else { + parentElement._parent[keyName] = nativeType(value); + } + } catch (e) {} + }; + + if (acceptedFiles && acceptedFiles.length > 0) { + const file = Array.from(acceptedFiles)[0]; + + const text = await file.text(); + + const res = JSON.parse( + xml2json(text, { + compact: true, + trim: true, + ignoreDeclaration: true, + ignoreInstruction: true, + ignoreAttributes: true, + ignoreComment: true, + ignoreCdata: false, + ignoreDoctype: true, + cdataFn: removeJsonTextAttribute, + textFn: removeJsonTextAttribute, + }), + ); + + if ('myanimelist' in res) { + setReadList(res.myanimelist.manga); + } + } + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + 'text/xml': ['.xml'], + }, + }); + + return ( + <div className="flex w-full flex-col gap-2"> + <Label>Файл списку</Label> + <div + {...getRootProps({ + className: clsx( + 'w-full h-28 p-4', + 'flex justify-center items-center', + 'cursor-pointer bg-secondary/60 rounded-lg text-center', + 'transition duration-100', + 'hover:bg-secondary/90', + isDragActive && 'bg-secondary/90', + ), + })} + > + <input {...getInputProps()} /> + {isDragActive ? ( + <P className="text-sm text-muted-foreground"> + Перетягніть файл сюди... + </P> + ) : readList.length === 0 ? ( + <P className="text-sm text-muted-foreground"> + Перетягніть сюди <span>.XML</span> файл, або натисніть, + щоб завантажити + </P> + ) : ( + <FoundList readList={readList} /> + )} + </div> + <Small className="text-muted-foreground"> + <span> + Ви можете імпортувати свій список з{' '} + <Link + target="_blank" + href="https://myanimelist.net/panel.php?go=export" + className="rounded-sm bg-primary px-1 text-primary-foreground hover:bg-primary/60 hover:!text-primary-foreground" + > + MyAnimeList + </Link>{' '} + або{' '} + <span className="rounded-sm bg-primary px-1 text-primary-foreground"> + Shikimori + </span> + </span> + </Small> + </div> + ); +}; + +export default Component; diff --git a/features/modals/user-settings-modal/readlist-form/readlist-form.tsx b/features/modals/user-settings-modal/readlist-form/readlist-form.tsx new file mode 100644 index 00000000..6d13055e --- /dev/null +++ b/features/modals/user-settings-modal/readlist-form/readlist-form.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useQueryClient } from '@tanstack/react-query'; +import { useSnackbar } from 'notistack'; +import { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; + +import importRead from '@/services/api/settings/importRead'; +import { useModalContext } from '@/services/providers/modal-provider'; + +import General from './general'; + +const Component = () => { + const { enqueueSnackbar } = useSnackbar(); + const [tab, setTab] = useState<'general' | 'aniList'>('general'); + const { closeModal } = useModalContext(); + const queryClient = useQueryClient(); + const [rewrite, setRewrite] = useState(true); + const [readList, setReadList] = useState<Record<string, any>[]>([]); + const [importing, setImporting] = useState<boolean>(false); + + const handleCompleteImport = async () => { + setImporting(true); + + if (readList && readList.length > 0) { + try { + await importRead({ + params: { + overwrite: rewrite, + content: readList, + }, + }); + + enqueueSnackbar( + <span> + Ви успішно імпортували{' '} + <span className="font-bold">{readList.length}</span>{' '} + манґи та ранобе до Вашого списку. + </span>, + { variant: 'success' }, + ); + + await queryClient.invalidateQueries(); + closeModal(); + } catch (e) {} + } + + setImporting(false); + }; + + useEffect(() => { + setReadList([]); + }, [tab]); + + return ( + <div className="flex flex-col gap-6 p-6"> + <General readList={readList} setReadList={setReadList} /> + + <div className="flex w-full items-center justify-between space-x-2"> + <Label htmlFor="rewrite"> + Переписати манґу та ранобе, які вже додані до списку + </Label> + <Switch + checked={rewrite} + onCheckedChange={(checked) => setRewrite(checked)} + id="rewrite" + /> + </div> + + <div className="w-full"> + <Button + variant="default" + onClick={handleCompleteImport} + disabled={readList.length === 0} + type="submit" + className="w-full" + > + {importing && ( + <span className="loading loading-spinner"></span> + )} + Імпортувати + </Button> + </div> + </div> + ); +}; + +export default Component; diff --git a/features/modals/user-settings-modal/user-settings-modal.tsx b/features/modals/user-settings-modal/user-settings-modal.tsx index d63dedf5..05a35349 100644 --- a/features/modals/user-settings-modal/user-settings-modal.tsx +++ b/features/modals/user-settings-modal/user-settings-modal.tsx @@ -16,9 +16,10 @@ import { Label } from '@/components/ui/label'; import CustomizationForm from '@/features/modals/user-settings-modal/customization-form'; import EmailForm from '@/features/modals/user-settings-modal/email-form'; -import GeneralForm from '@/features/modals/user-settings-modal/general-form'; +import GeneralForm from '@/features/modals/user-settings-modal/general-form/general-form'; import NotificationsForm from '@/features/modals/user-settings-modal/notifications-form'; import PasswordForm from '@/features/modals/user-settings-modal/password-form'; +import ReadListForm from '@/features/modals/user-settings-modal/readlist-form/readlist-form'; import UsernameForm from '@/features/modals/user-settings-modal/username-form'; import WatchListForm from '@/features/modals/user-settings-modal/watchlist-form/watchlist-form'; @@ -32,6 +33,7 @@ type Tab = | 'email' | 'notifications' | 'watchList' + | 'readList' | 'customization'; const DATA: { @@ -47,11 +49,17 @@ const DATA: { form: <GeneralForm />, }, { - title: 'Імпорт', + title: 'Імпорт аніме', description: 'Імпорт Вашого списку аніме', slug: 'watchList', form: <WatchListForm />, }, + { + title: 'Імпорт манґи та ранобе', + description: 'Імпорт Вашого списку манґи та ранобе', + slug: 'readList', + form: <ReadListForm />, + }, { title: 'Email', description: 'Змінити свій email', @@ -86,7 +94,7 @@ const DATA: { const Header = ({ title, onBack }: { title: string; onBack?: () => void }) => { return ( - <div className="flex items-center justify-center gap-2 border-b px-6 pb-4 sm:justify-start"> + <div className="flex items-center justify-center gap-2 border-b px-6 py-4 sm:justify-start"> {onBack && ( <Button onClick={onBack} @@ -110,7 +118,7 @@ const Tabs = ({ setActiveTab: Dispatch<SetStateAction<Tab | undefined>>; }) => { return ( - <div className="flex size-full flex-col border-r-secondary/60 py-6 md:border-r"> + <div className="flex size-full flex-col border-r-secondary/60 md:border-r"> <Header title="Налаштування" /> <ul className="w-full p-0 [&_li>*]:rounded-none"> {DATA.map((tab) => ( @@ -118,7 +126,7 @@ const Tabs = ({ <a onClick={() => setActiveTab(tab.slug)} className={cn( - 'flex flex-col items-start justify-center px-8 py-4', + 'flex flex-col items-start justify-center px-6 py-4', activeTab === tab.slug ? 'bg-muted' : 'hover:bg-secondary/30', @@ -157,7 +165,7 @@ const Component = () => { {isDesktop && ( <Tabs setActiveTab={setActiveTab} activeTab={activeTab} /> )} - <div className="flex flex-1 flex-col overflow-hidden pt-6"> + <div className="flex flex-1 flex-col overflow-hidden"> {activeForm && ( <Header onBack={() => setActiveTab(undefined)} diff --git a/features/modals/watch-edit-modal.tsx b/features/modals/watch-edit-modal.tsx index b1295e46..22d471a2 100644 --- a/features/modals/watch-edit-modal.tsx +++ b/features/modals/watch-edit-modal.tsx @@ -19,8 +19,8 @@ import { SelectTrigger, } from '@/components/ui/select'; -import useAddToList from '@/services/hooks/watch/use-add-to-list'; -import useDeleteFromList from '@/services/hooks/watch/use-delete-from-list'; +import useAddWatch from '@/services/hooks/watch/use-add-watch'; +import useDeleteWatch from '@/services/hooks/watch/use-delete-watch'; import useWatch from '@/services/hooks/watch/use-watch'; import { useModalContext } from '@/services/providers/modal-provider'; import { WATCH_STATUS } from '@/utils/constants'; @@ -41,12 +41,10 @@ const Component = ({ slug }: Props) => { const { closeModal } = useModalContext(); const { data: watch } = useWatch({ slug }); - const { mutate: addToList, isPending: addToListLoading } = useAddToList({ - slug, - }); + const { mutate: addWatch, isPending: addToListLoading } = useAddWatch(); - const { mutate: deleteFromList, isPending: deleteFromListLoading } = - useDeleteFromList({ slug }); + const { mutate: deleteWatch, isPending: deleteFromListLoading } = + useDeleteWatch(); const [selectedStatus, setSelectedStatus] = useState< API.WatchStatus | undefined @@ -58,7 +56,7 @@ const Component = ({ slug }: Props) => { }); const onDelete = async () => { - deleteFromList(); + deleteWatch({ params: { slug } }); closeModal(); }; @@ -161,7 +159,13 @@ const Component = ({ slug }: Props) => { <Button variant="accent" onClick={form.handleSubmit((data) => - addToList({ status: selectedStatus!, ...data }), + addWatch({ + params: { + slug, + status: selectedStatus!, + ...data, + }, + }), )} type="submit" disabled={addToListLoading || deleteFromListLoading} diff --git a/features/novel/novel-list-navbar/novel-list-navbar.component.tsx b/features/novel/novel-list-navbar/novel-list-navbar.component.tsx new file mode 100644 index 00000000..ed620ab9 --- /dev/null +++ b/features/novel/novel-list-navbar/novel-list-navbar.component.tsx @@ -0,0 +1,26 @@ +import { Suspense } from 'react'; + +import ReadFiltersModal from '@/features/modals/read-filters-modal'; + +import { cn } from '@/utils/utils'; + +import Search from './search'; + +const NovelListNavbar = () => { + return ( + <div + className={cn( + 'flex items-end gap-2 border-b border-b-transparent bg-transparent transition md:gap-4', + )} + > + <Suspense> + <Search /> + </Suspense> + <div className="lg:hidden"> + <ReadFiltersModal sort_type="novel" content_type="novel" /> + </div> + </div> + ); +}; + +export default NovelListNavbar; diff --git a/features/novel/novel-list-navbar/search.tsx b/features/novel/novel-list-navbar/search.tsx new file mode 100644 index 00000000..f34cb498 --- /dev/null +++ b/features/novel/novel-list-navbar/search.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; + +import { Input } from '@/components/ui/input'; + +import createQueryString from '@/utils/create-query-string'; + +const Search = () => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams()!; + + const [search, setSearch] = useState(searchParams.get('search')); + + const handleChangeSearch = (value: string) => { + const query = createQueryString( + 'search', + value, + createQueryString( + 'page', + '1', + createQueryString( + 'iPage', + '1', + new URLSearchParams(searchParams), + ), + ), + ); + setSearch(value); + + router.replace(`${pathname}?${query}`); + }; + + return ( + <div className="flex flex-1 flex-col gap-4"> + <Input + value={search || ''} + onChange={(event) => handleChangeSearch(event.target.value)} + type="text" + placeholder="Введіть назву ранобе..." + /> + </div> + ); +}; + +export default Search; diff --git a/features/novel/novel-list/novel-list-skeleton.tsx b/features/novel/novel-list/novel-list-skeleton.tsx new file mode 100644 index 00000000..37c8eec8 --- /dev/null +++ b/features/novel/novel-list/novel-list-skeleton.tsx @@ -0,0 +1,15 @@ +import { range } from '@antfu/utils'; + +import SkeletonCard from '@/components/skeletons/content-card'; + +const NovelListSkeleton = () => { + return ( + <div className="grid grid-cols-2 gap-4 md:grid-cols-5 lg:gap-8"> + {range(1, 20).map((v) => ( + <SkeletonCard key={v} /> + ))} + </div> + ); +}; + +export default NovelListSkeleton; diff --git a/features/novel/novel-list/novel-list.component.tsx b/features/novel/novel-list/novel-list.component.tsx new file mode 100644 index 00000000..6280f415 --- /dev/null +++ b/features/novel/novel-list/novel-list.component.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { FC } from 'react'; + +import FiltersNotFound from '@/components/filters-not-found'; +import LoadMoreButton from '@/components/load-more-button'; +import NovelCard from '@/components/novel-card'; +import Block from '@/components/ui/block'; +import Pagination from '@/components/ui/pagination'; +import Stack from '@/components/ui/stack'; + +import useNovelCatalog from '@/services/hooks/novel/use-novel-catalog'; + +import NovelListSkeleton from './novel-list-skeleton'; +import { useNextPage, useUpdatePage } from './novel-list.hooks'; + +interface Props { + searchParams: Record<string, string>; +} + +const NovelList: FC<Props> = () => { + const searchParams = useSearchParams(); + + const query = searchParams.get('search'); + const media_type = searchParams.getAll('types'); + const status = searchParams.getAll('statuses'); + + const years = searchParams.getAll('years'); + const genres = searchParams.getAll('genres'); + + const only_translated = searchParams.get('only_translated'); + + const sort = searchParams.get('sort') || 'score'; + const order = searchParams.get('order') || 'desc'; + + const page = searchParams.get('page'); + const iPage = searchParams.get('iPage'); + + const dataKeys = { + query, + media_type, + status, + years, + genres, + only_translated: Boolean(only_translated), + sort: sort ? [`${sort}:${order}`] : undefined, + page: Number(page), + iPage: Number(iPage), + }; + + const { + fetchNextPage, + isFetchingNextPage, + isLoading, + hasNextPage, + list, + pagination, + } = useNovelCatalog(dataKeys); + + const updatePage = useUpdatePage(dataKeys); + const nextPage = useNextPage({ fetchNextPage, pagination }); + + if (isLoading && !isFetchingNextPage) { + return <NovelListSkeleton />; + } + + if (list === undefined || list.length === 0) { + return <FiltersNotFound />; + } + + return ( + <Block> + <Stack extended={true} size={5} extendedSize={5}> + {list.map((novel: API.Novel) => { + return <NovelCard key={novel.slug} novel={novel} />; + })} + </Stack> + {hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={nextPage} + /> + )} + {list && pagination && pagination.pages > 1 && ( + <div className="sticky bottom-2 z-10 flex items-center justify-center"> + <div className="w-fit rounded-lg border border-secondary/60 bg-background p-2 shadow"> + <Pagination + page={Number(iPage)} + pages={pagination.pages} + setPage={updatePage} + /> + </div> + </div> + )} + </Block> + ); +}; + +export default NovelList; diff --git a/features/novel/novel-list/novel-list.hooks.ts b/features/novel/novel-list/novel-list.hooks.ts new file mode 100644 index 00000000..38c98f16 --- /dev/null +++ b/features/novel/novel-list/novel-list.hooks.ts @@ -0,0 +1,63 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import useNovelCatalog, { + Props, +} from '@/services/hooks/novel/use-novel-catalog'; +import createQueryString from '@/utils/create-query-string'; + +export const useUpdatePage = ({ page, iPage }: Props) => { + const queryClient = useQueryClient(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return (newPage: number) => { + if (newPage !== Number(page) || newPage !== Number(iPage)) { + queryClient.removeQueries({ + queryKey: ['novel-list', page, {}], + exact: false, + }); + const query = createQueryString( + 'iPage', + String(newPage), + createQueryString( + 'page', + String(newPage), + new URLSearchParams(searchParams), + ), + ); + router.push(`${pathname}?${query.toString()}`, { scroll: true }); + } + }; +}; + +interface UseLoadInfinitePageProps { + pagination?: ReturnType<typeof useNovelCatalog>['pagination']; + fetchNextPage: ReturnType<typeof useNovelCatalog>['fetchNextPage']; +} + +export const useNextPage = ({ + fetchNextPage, + pagination, +}: UseLoadInfinitePageProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + + return () => { + if (pagination) { + const query = createQueryString( + 'iPage', + String(pagination.page + 1), + new URLSearchParams(searchParams), + ); + + router.replace(`${pathname}?${query.toString()}`, { + scroll: false, + }); + + fetchNextPage(); + } + }; +}; diff --git a/features/novel/novel-view/actions/actions.component.tsx b/features/novel/novel-view/actions/actions.component.tsx new file mode 100644 index 00000000..9eaf2c60 --- /dev/null +++ b/features/novel/novel-view/actions/actions.component.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import CommentsButton from '@/components/comments-button'; +import ReadListButton from '@/components/readlist-button/readlist-button'; + +import useSession from '@/services/hooks/auth/use-session'; +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; + +import ReadStats from './read-stats'; + +const Actions: FC = () => { + const params = useParams(); + const { user } = useSession(); + const { data: novel } = useNovelInfo({ slug: String(params.slug) }); + + return ( + <div className="flex flex-col gap-12"> + <div className="flex flex-col gap-4"> + <ReadListButton + content_type="novel" + disabled={!user} + additional + slug={String(params.slug)} + /> + <ReadStats /> + {novel && ( + <CommentsButton + comments_count={novel.comments_count} + slug={novel?.slug} + content_type="novel" + /> + )} + </div> + </div> + ); +}; + +export default Actions; diff --git a/features/novel/novel-view/actions/read-stats.tsx b/features/novel/novel-view/actions/read-stats.tsx new file mode 100644 index 00000000..d17056e8 --- /dev/null +++ b/features/novel/novel-view/actions/read-stats.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import MaterialSymbolsAddRounded from '~icons/material-symbols/add-rounded'; +import MaterialSymbolsRemoveRounded from '~icons/material-symbols/remove-rounded'; + +import H3 from '@/components/typography/h3'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Progress } from '@/components/ui/progress'; +import Rating from '@/components/ui/rating'; + +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; +import useAddRead from '@/services/hooks/read/use-add-read'; +import useRead from '@/services/hooks/read/use-read'; + +const ReadStats = () => { + const params = useParams(); + + const { data: read, isError: readError } = useRead({ + slug: String(params.slug), + content_type: 'novel', + }); + const { data } = useNovelInfo({ slug: String(params.slug) }); + + const { mutate: mutateAddRead, variables, isPending } = useAddRead(); + + const handleAddEpisode = () => { + if (read) { + const chapters = (variables?.params?.chapters || read.chapters) + 1; + + if (read.content.chapters && chapters > read.content.chapters) + return; + + let status = read.status; + + if (chapters === read.content.chapters) { + status = 'completed'; + } + + if (!read.chapters && read.status === 'planned') { + status = 'reading'; + } + + mutateAddRead({ + params: { + content_type: 'novel', + note: read.note, + volumes: read.volumes, + rereads: read.rereads, + score: read.score, + status, + slug: read.content.slug, + chapters, + }, + }); + } + }; + + const handleRemoveEpisode = () => { + if (read) { + const chapters = (variables?.params?.chapters || read.chapters) - 1; + + if (chapters < 0) return; + + mutateAddRead({ + params: { + note: read.note, + volumes: read.volumes, + rereads: read.rereads, + score: read.score, + status: read.status, + content_type: 'novel', + slug: read.content.slug, + chapters, + }, + }); + } + }; + + const handleRating = (value: number) => { + if (read) { + mutateAddRead({ + params: { + note: read.note, + volumes: read.volumes, + chapters: read.chapters, + rereads: read.rereads, + status: read.status, + content_type: 'novel', + slug: read.content.slug, + score: value * 2, + }, + }); + } + }; + + if (!read || readError || !data) { + return null; + } + + return ( + <div className="flex flex-col gap-4"> + <div className="flex justify-between gap-4 rounded-lg border border-secondary/60 bg-secondary/30 p-4"> + <Rating + // className="rating-md lg:flex" + onChange={handleRating} + totalStars={5} + precision={0.5} + value={read.score ? read.score / 2 : 0} + /> + <H3> + {read.score} + <Label className="text-sm font-normal text-muted-foreground"> + /10 + </Label> + </H3> + </div> + <div className="rounded-lg border border-secondary/60 bg-secondary/30 p-4"> + <div className="flex justify-between gap-2 overflow-hidden"> + <Label className="min-h-[24px] self-center overflow-hidden text-ellipsis"> + Розділи + </Label> + <div className="inline-flex"> + <Button + variant="secondary" + size="icon-sm" + className="rounded-r-none" + onClick={handleRemoveEpisode} + > + <MaterialSymbolsRemoveRounded /> + </Button> + <Button + variant="secondary" + size="icon-sm" + className="rounded-l-none" + onClick={handleAddEpisode} + > + <MaterialSymbolsAddRounded /> + </Button> + </div> + </div> + <H3> + {isPending ? variables?.params?.chapters : read.chapters} + <Label className="text-sm font-normal text-muted-foreground"> + /{read.content.chapters || '?'} + </Label> + </H3> + <Progress + className="mt-2 h-2" + max={read.content.chapters || read.chapters} + value={ + isPending ? variables?.params?.chapters : read.chapters + } + /> + </div> + </div> + ); +}; + +export default ReadStats; diff --git a/features/novel/novel-view/characters/characters.component.tsx b/features/novel/novel-view/characters/characters.component.tsx new file mode 100644 index 00000000..df04c64f --- /dev/null +++ b/features/novel/novel-view/characters/characters.component.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import LoadMoreButton from '@/components/load-more-button'; + +import useNovelCharacters from '@/services/hooks/novel/use-novel-characters'; + +import MainCharacters from './main-characters'; +import OtherCharacters from './other-characters'; + +interface Props { + extended?: boolean; +} + +const Characters: FC<Props> = ({ extended }) => { + const params = useParams(); + const { fetchNextPage, hasNextPage, isFetchingNextPage, ref } = + useNovelCharacters({ slug: String(params.slug) }); + + return ( + <div className="flex flex-col gap-12"> + <MainCharacters extended={extended} /> + {extended && <OtherCharacters extended={extended} />} + {extended && hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + )} + </div> + ); +}; + +export default Characters; diff --git a/features/novel/novel-view/characters/main-characters.tsx b/features/novel/novel-view/characters/main-characters.tsx new file mode 100644 index 00000000..b09a7cf8 --- /dev/null +++ b/features/novel/novel-view/characters/main-characters.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useNovelCharacters from '@/services/hooks/novel/use-novel-characters'; + +import CharacterCard from '../../../../components/character-card'; + +interface Props { + extended?: boolean; +} + +const MainCharacters: FC<Props> = ({ extended }) => { + const params = useParams(); + const { list } = useNovelCharacters({ slug: String(params.slug) }); + + if (!list || list.length === 0) { + return null; + } + + const main = list.filter((ch) => ch.main); + + return ( + <Block> + <Header + title={'Головні Персонажі'} + href={!extended ? params.slug + '/characters' : undefined} + /> + <Stack size={5} className="grid-min-6" extended={extended}> + {(extended ? main : main.slice(0, 5)).map((ch) => ( + <CharacterCard + key={ch.character.slug} + character={ch.character} + /> + ))} + </Stack> + </Block> + ); +}; + +export default MainCharacters; diff --git a/features/novel/novel-view/characters/other-characters.tsx b/features/novel/novel-view/characters/other-characters.tsx new file mode 100644 index 00000000..212880c1 --- /dev/null +++ b/features/novel/novel-view/characters/other-characters.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import CharacterCard from '@/components/character-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useNovelCharacters from '@/services/hooks/novel/use-novel-characters'; + +interface Props { + extended?: boolean; +} + +const OtherCharacters: FC<Props> = ({ extended }) => { + const params = useParams(); + const { list } = useNovelCharacters({ slug: String(params.slug) }); + + if (!list || list.length === 0) { + return null; + } + + const other = list.filter((ch) => !ch.main); + + if (other.length === 0) { + return null; + } + + return ( + <Block> + <Header title={'Другорядні Персонажі'} /> + <Stack size={5} className="grid-min-6" extended={extended}> + {other.map((ch) => ( + <CharacterCard + key={ch.character.slug} + character={ch.character} + /> + ))} + </Stack> + </Block> + ); +}; + +export default OtherCharacters; diff --git a/features/novel/novel-view/cover.component.tsx b/features/novel/novel-view/cover.component.tsx new file mode 100644 index 00000000..e5d19728 --- /dev/null +++ b/features/novel/novel-view/cover.component.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import ContentCard from '@/components/content-card/content-card'; +import FavoriteButton from '@/components/favorite-button'; + +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; + +const Cover: FC = () => { + const params = useParams(); + const { data: novel } = useNovelInfo({ slug: String(params.slug) }); + + return ( + <div className="flex items-center px-16 md:px-48 lg:px-0"> + <ContentCard imageProps={{ priority: true }} image={novel?.image}> + <div className="absolute bottom-2 right-2 z-[1]"> + <FavoriteButton + slug={String(params.slug)} + content_type="novel" + /> + </div> + + <div className="absolute bottom-0 left-0 h-24 w-full bg-gradient-to-t from-black to-transparent" /> + </ContentCard> + </div> + ); +}; + +export default Cover; diff --git a/features/novel/novel-view/description.component.tsx b/features/novel/novel-view/description.component.tsx new file mode 100644 index 00000000..358494da --- /dev/null +++ b/features/novel/novel-view/description.component.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useState } from 'react'; + +import MDViewer from '@/components/markdown/viewer/MD-viewer'; +import TextExpand from '@/components/text-expand'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; + +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; + +const Description = () => { + const [active, setActive] = useState<'synopsis_ua' | 'synopsis_en'>( + 'synopsis_ua', + ); + const params = useParams(); + const { data } = useNovelInfo({ slug: String(params.slug) }); + + if (!data || (!data.synopsis_ua && !data.synopsis_en)) { + return null; + } + + return ( + <Block> + <Header title="Опис"> + <ToggleGroup + type="single" + value={active} + onValueChange={(value: 'synopsis_ua' | 'synopsis_en') => + value && setActive(value) + } + variant="outline" + size="badge" + > + {data.synopsis_ua && ( + <ToggleGroupItem + value="synopsis_ua" + aria-label="Опис українскою" + > + UA + </ToggleGroupItem> + )} + {data.synopsis_en && ( + <ToggleGroupItem + value="synopsis_en" + aria-label="Опис англійською" + > + EN + </ToggleGroupItem> + )} + </ToggleGroup> + </Header> + <TextExpand> + <MDViewer> + {data[active] || data.synopsis_ua || data.synopsis_en} + </MDViewer> + </TextExpand> + </Block> + ); +}; + +export default Description; diff --git a/features/novel/novel-view/details/chapters.tsx b/features/novel/novel-view/details/chapters.tsx new file mode 100644 index 00000000..5feb97dd --- /dev/null +++ b/features/novel/novel-view/details/chapters.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; + +interface Props { + chapters: number; +} + +const Chapters: FC<Props> = ({ chapters }) => { + if (!chapters) { + return null; + } + + return ( + <div className="flex flex-wrap"> + <div className="w-24"> + <Label className="text-muted-foreground">Розділи:</Label> + </div> + <div className="flex-1"> + <Label>{chapters}</Label> + </div> + </div> + ); +}; + +export default Chapters; diff --git a/features/novel/novel-view/details/details.component.tsx b/features/novel/novel-view/details/details.component.tsx new file mode 100644 index 00000000..b8420470 --- /dev/null +++ b/features/novel/novel-view/details/details.component.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useParams } from 'next/navigation'; + +import Block from '@/components/ui/block'; +import Card from '@/components/ui/card'; +import Header from '@/components/ui/header'; + +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; + +import Chapters from './chapters'; +import Magazines from './magazines'; +import MediaType from './media-type'; +import Status from './status'; +import Volumes from './volumes'; + +const Details = () => { + const params = useParams(); + + const { data } = useNovelInfo({ slug: String(params.slug) }); + + if (!data) { + return null; + } + + return ( + <Block> + <Header title="Деталі" /> + <Card> + <MediaType media_type={data.media_type} /> + <Status status={data.status} /> + <Volumes volumes={data.volumes} /> + <Chapters chapters={data.chapters} /> + <Magazines magazines={data.magazines} /> + </Card> + </Block> + ); +}; + +export default Details; diff --git a/features/novel/novel-view/details/magazines.tsx b/features/novel/novel-view/details/magazines.tsx new file mode 100644 index 00000000..d49110db --- /dev/null +++ b/features/novel/novel-view/details/magazines.tsx @@ -0,0 +1,33 @@ +import Link from 'next/link'; +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; + +interface Props { + magazines: API.Magazine[]; +} + +const Magazines: FC<Props> = ({ magazines }) => { + if (!magazines || magazines.length === 0) { + return null; + } + + return ( + <div className="flex flex-wrap"> + <div className="w-24"> + <Label className="text-muted-foreground">Видавець:</Label> + </div> + <div className="flex flex-1 items-center gap-1"> + {magazines.map((magazine) => ( + <Label key={magazine.slug}> + <Link href={`/novel?magazines=${magazine.slug}`}> + {magazine.name_en} + </Link> + </Label> + ))} + </div> + </div> + ); +}; + +export default Magazines; diff --git a/features/novel/novel-view/details/media-type.tsx b/features/novel/novel-view/details/media-type.tsx new file mode 100644 index 00000000..9c254c6c --- /dev/null +++ b/features/novel/novel-view/details/media-type.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; + +import { NOVEL_MEDIA_TYPE } from '@/utils/constants'; + +interface Props { + media_type: API.NovelMediaType; +} + +const MediaType: FC<Props> = ({ media_type }) => { + if (!media_type) { + return null; + } + + return ( + <div className="flex flex-wrap"> + <div className="w-24"> + <Label className="text-muted-foreground">Тип:</Label> + </div> + <div className="flex-1"> + <Label>{NOVEL_MEDIA_TYPE[media_type].title_ua}</Label> + </div> + </div> + ); +}; + +export default MediaType; diff --git a/features/novel/novel-view/details/status.tsx b/features/novel/novel-view/details/status.tsx new file mode 100644 index 00000000..92c61d47 --- /dev/null +++ b/features/novel/novel-view/details/status.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; + +import { RELEASE_STATUS } from '@/utils/constants'; + +interface Props { + status: API.Status; +} + +const Status: FC<Props> = ({ status }) => { + if (!status) { + return null; + } + + return ( + <div className="flex flex-wrap items-center"> + <div className="w-24"> + <Label className="text-muted-foreground">Статус:</Label> + </div> + <div className="flex-1"> + <Badge variant="status" bgColor={RELEASE_STATUS[status].color}> + {RELEASE_STATUS[status].title_ua} + </Badge> + </div> + </div> + ); +}; + +export default Status; diff --git a/features/novel/novel-view/details/volumes.tsx b/features/novel/novel-view/details/volumes.tsx new file mode 100644 index 00000000..1292437e --- /dev/null +++ b/features/novel/novel-view/details/volumes.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; + +interface Props { + volumes: number; +} + +const Volumes: FC<Props> = ({ volumes }) => { + if (!volumes) { + return null; + } + + return ( + <div className="flex flex-wrap"> + <div className="w-24"> + <Label className="text-muted-foreground">Томи:</Label> + </div> + <div className="flex-1"> + <Label>{volumes}</Label> + </div> + </div> + ); +}; + +export default Volumes; diff --git a/features/novel/novel-view/franchise.component.tsx b/features/novel/novel-view/franchise.component.tsx new file mode 100644 index 00000000..df379295 --- /dev/null +++ b/features/novel/novel-view/franchise.component.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import AnimeCard from '@/components/anime-card'; +import MangaCard from '@/components/manga-card'; +import NovelCard from '@/components/novel-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; +import useFranchise from '@/services/hooks/related/use-franchise'; + +interface Props { + extended?: boolean; +} + +const Franchise: FC<Props> = ({ extended }) => { + const params = useParams(); + const { data: novel } = useNovelInfo({ slug: String(params.slug) }); + + const { data: franchise } = useFranchise({ + slug: String(params.slug), + content_type: 'novel', + }); + + if (!novel || !novel.has_franchise) { + return null; + } + + if (!franchise) { + return null; + } + + const filteredData = extended ? franchise.list : franchise.list.slice(0, 4); + + return ( + <Block> + <Header + title={`Пов’язане`} + href={!extended ? params.slug + '/franchise' : undefined} + /> + <Stack extended={extended} size={4} className="grid-min-10"> + {filteredData.map((content) => { + if (content.data_type === 'anime') { + return <AnimeCard key={content.slug} anime={content} />; + } + + if (content.data_type === 'manga') { + return <MangaCard key={content.slug} manga={content} />; + } + + if (content.data_type === 'novel') { + return <NovelCard key={content.slug} novel={content} />; + } + })} + </Stack> + </Block> + ); +}; + +export default Franchise; diff --git a/features/novel/novel-view/links/links.component.tsx b/features/novel/novel-view/links/links.component.tsx new file mode 100644 index 00000000..5a9518bd --- /dev/null +++ b/features/novel/novel-view/links/links.component.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC, useState } from 'react'; +import MaterialSymbolsInfoIRounded from '~icons/material-symbols/info-i-rounded'; +import MaterialSymbolsPlayArrowRounded from '~icons/material-symbols/play-arrow-rounded'; + +import TextExpand from '@/components/text-expand'; +import P from '@/components/typography/p'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import HorizontalCard from '@/components/ui/horizontal-card'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; + +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; + +interface Props { + extended?: boolean; +} + +const Links: FC<Props> = ({ extended }) => { + const [isExpanded, setIsExpanded] = useState(false); + const [active, setActive] = useState<API.External['type']>('general'); + const params = useParams(); + const { data: novel } = useNovelInfo({ slug: String(params.slug) }); + + if (!novel) { + return null; + } + + if (novel.external.length === 0) { + return null; + } + + const readLinksData = novel.external.filter((l) => l.type === 'read'); + const generalLinksData = novel.external.filter((l) => l.type === 'general'); + + const linksData = active === 'general' ? generalLinksData : readLinksData; + + const handleChangeActive = (value: API.External['type']) => { + if (value) { + setActive(value); + setIsExpanded(false); + } + }; + + return ( + <Block> + <Header title="Посилання"> + <ToggleGroup + type="single" + value={active} + onValueChange={handleChangeActive} + variant="outline" + size="badge" + > + <ToggleGroupItem + value="general" + aria-label="Загальні посилання" + > + <MaterialSymbolsInfoIRounded /> + </ToggleGroupItem> + {readLinksData.length > 0 && ( + <ToggleGroupItem + value="read" + aria-label="Посилання для читання" + > + <MaterialSymbolsPlayArrowRounded /> + </ToggleGroupItem> + )} + </ToggleGroup> + </Header> + <TextExpand + expanded={isExpanded} + setExpanded={setIsExpanded} + className="max-h-40" + > + <div className="flex flex-col gap-4"> + {linksData.map((link) => ( + <HorizontalCard + key={link.url} + title={link.text} + href={link.url} + imageRatio={1} + imageContainerClassName="w-8" + descriptionClassName="break-all" + image={<P>{link.text[0]}</P>} + /> + ))} + </div> + </TextExpand> + </Block> + ); +}; + +export default Links; diff --git a/features/novel/novel-view/read-stats/read-stats.component.tsx b/features/novel/novel-view/read-stats/read-stats.component.tsx new file mode 100644 index 00000000..2f615fce --- /dev/null +++ b/features/novel/novel-view/read-stats/read-stats.component.tsx @@ -0,0 +1,40 @@ +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; + +import Readlist from './readlist'; +import Score from './score'; + +const ReadStats = () => { + return ( + <Block> + <Header title="Статистика"> + <ToggleGroup + type="single" + value="MAL" + variant="outline" + size="badge" + > + <ToggleGroupItem value="MAL" aria-label="MAL"> + MAL + </ToggleGroupItem> + </ToggleGroup> + </Header> + <Tabs defaultValue="readlist"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="readlist">У списках</TabsTrigger> + <TabsTrigger value="score">Оцінки</TabsTrigger> + </TabsList> + <TabsContent value="readlist"> + <Readlist /> + </TabsContent> + <TabsContent value="score"> + <Score /> + </TabsContent> + </Tabs> + </Block> + ); +}; + +export default ReadStats; diff --git a/features/novel/novel-view/read-stats/readlist.tsx b/features/novel/novel-view/read-stats/readlist.tsx new file mode 100644 index 00000000..28ffcef1 --- /dev/null +++ b/features/novel/novel-view/read-stats/readlist.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { createElement } from 'react'; + +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; +import { READ_STATUS } from '@/utils/constants'; + +import Stats from './stats'; + +const Readlist = () => { + const params = useParams(); + const { data } = useNovelInfo({ slug: String(params.slug) }); + + if (!data) { + return null; + } + + const sumStats = + data.stats.completed + + data.stats.on_hold + + data.stats.dropped + + data.stats.planned + + data.stats.reading; + + const stats = Object.keys(data.stats).reduce( + (acc: Hikka.ListStat[], stat) => { + if (!stat.includes('score')) { + const status = READ_STATUS[stat as API.ReadStatus]; + const percentage = + (100 * data.stats[stat as API.StatType]) / sumStats; + + acc.push({ + percentage, + value: data.stats[stat as API.StatType], + icon: status.icon && createElement(status.icon), + color: status.color!, + }); + } + + return acc; + }, + [], + ); + + return <Stats stats={stats} />; +}; + +export default Readlist; diff --git a/features/novel/novel-view/read-stats/score.tsx b/features/novel/novel-view/read-stats/score.tsx new file mode 100644 index 00000000..817bcaf0 --- /dev/null +++ b/features/novel/novel-view/read-stats/score.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useParams } from 'next/navigation'; + +import Small from '@/components/typography/small'; + +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; + +import Stats from './stats'; + +const Score = () => { + const params = useParams(); + const { data } = useNovelInfo({ slug: String(params.slug) }); + + if (!data) { + return null; + } + + const sumStats = + data.stats.score_1 + + data.stats.score_2 + + data.stats.score_3 + + data.stats.score_4 + + data.stats.score_5 + + data.stats.score_6 + + data.stats.score_7 + + data.stats.score_8 + + data.stats.score_9 + + data.stats.score_10; + + const stats = Object.keys(data.stats) + .reverse() + .reduce((acc: Hikka.ListStat[], stat) => { + if ( + stat.includes('score') && + data.stats[stat as API.StatType] > 0 + ) { + const percentage = + (100 * data.stats[stat as API.StatType]) / sumStats; + + acc.push({ + icon: <Small>{stat.split('score_')[1]}</Small>, + percentage, + value: data.stats[stat as API.StatType], + }); + } + + return acc; + }, []); + + return <Stats stats={stats} />; +}; + +export default Score; diff --git a/features/novel/novel-view/read-stats/stats.tsx b/features/novel/novel-view/read-stats/stats.tsx new file mode 100644 index 00000000..2ebdc811 --- /dev/null +++ b/features/novel/novel-view/read-stats/stats.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { FC } from 'react'; +import { NumericFormat } from 'react-number-format'; + +import Small from '@/components/typography/small'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +interface Props { + stats: Hikka.ListStat[]; +} + +const Stats: FC<Props> = ({ stats }) => { + return ( + <div className="relative overflow-hidden rounded-lg border border-secondary/60 bg-secondary/30 p-4"> + <div className="flex flex-col justify-center gap-2"> + {stats.map((stat) => { + return ( + <Tooltip + key={`${stat.value}-${stat.percentage}`} + delayDuration={0} + > + <TooltipTrigger asChild> + <div className="flex items-center justify-between gap-2"> + <div className="flex w-full flex-1 items-center gap-2"> + {stat.icon && ( + <div className="flex size-6 items-center justify-center rounded-md bg-secondary"> + {stat.icon} + </div> + )} + <div className="relative h-2 w-full flex-1 overflow-hidden rounded-md"> + <div + className="absolute bottom-0 left-0 size-full bg-primary opacity-10" + style={{ + backgroundColor: stat.color, + }} + /> + <div + className="absolute bottom-0 left-0 flex h-2 w-full items-end justify-center bg-primary" + style={{ + backgroundColor: stat.color, + width: `${stat.percentage}%`, + }} + ></div> + </div> + </div> + <Small className="w-14 text-right text-muted-foreground"> + <NumericFormat + thousandSeparator + displayType="text" + value={stat.value} + decimalScale={1} + /> + </Small> + </div> + </TooltipTrigger> + <TooltipContent align="center" side="left"> + {stat.percentage.toFixed(2)}% + </TooltipContent> + </Tooltip> + ); + })} + </div> + </div> + ); +}; + +export default Stats; diff --git a/features/novel/novel-view/staff.component.tsx b/features/novel/novel-view/staff.component.tsx new file mode 100644 index 00000000..fb527df7 --- /dev/null +++ b/features/novel/novel-view/staff.component.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import PersonCard from '@/components/person-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; + +interface Props { + extended?: boolean; +} + +const Staff: FC<Props> = ({ extended }) => { + const params = useParams(); + const { data } = useNovelInfo({ slug: String(params.slug) }); + + if (!data || data.authors.length === 0) { + return null; + } + + const authors = data.authors; + + const filteredData = extended ? authors : authors.slice(0, 4); + + return ( + <Block> + <Header + title="Автори" + href={!extended ? params.slug + '/staff' : undefined} + /> + <Stack size={4} extended={extended}> + {filteredData.map((staff) => ( + <PersonCard + key={staff.person.slug} + person={staff.person} + roles={staff.roles} + /> + ))} + </Stack> + </Block> + ); +}; + +export default Staff; diff --git a/features/novel/novel-view/title.component.tsx b/features/novel/novel-view/title.component.tsx new file mode 100644 index 00000000..d6bbb058 --- /dev/null +++ b/features/novel/novel-view/title.component.tsx @@ -0,0 +1,91 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import MaterialSymbolsStarRounded from '~icons/material-symbols/star-rounded'; + +import EditButton from '@/components/edit-button'; +import H2 from '@/components/typography/h2'; +import P from '@/components/typography/p'; + +import useSession from '@/services/hooks/auth/use-session'; +import useNovelInfo from '@/services/hooks/novel/use-novel-info'; + +const Title = () => { + const { user: loggedUser } = useSession(); + const params = useParams(); + const { data } = useNovelInfo({ slug: String(params.slug) }); + + if (!data) { + return null; + } + + return ( + <div className="flex flex-col gap-4"> + <div className="flex justify-between gap-4"> + <div> + <div className="flex gap-4"> + <H2> + {data.title}{' '} + {data.start_date && ( + <span className="font-sans font-normal"> + ( + {new Date( + data.start_date * 1000, + ).getFullYear()} + ) + </span> + )} + </H2> + {loggedUser && ( + <EditButton + key={String(params.slug)} + slug={String(params.slug)} + content_type="novel" + className="hidden lg:flex" + /> + )} + </div> + <P className="text-sm text-muted-foreground"> + {data.title_original} + </P> + </div> + <div className="flex flex-col gap-2"> + {data.score > 0 && ( + <div className="flex items-start gap-2"> + <div className="font-display text-4xl font-bold"> + {data.score} + </div> + + <MaterialSymbolsStarRounded className="text-2xl" /> + </div> + )} + {loggedUser && ( + <EditButton + key={String(params.slug)} + slug={String(params.slug)} + content_type="novel" + className="flex lg:hidden" + /> + )} + </div> + </div> + {data.genres.length > 0 && ( + <div className="flex flex-wrap gap-1"> + {data.genres.map((genre) => ( + <span key={genre.slug} className="text-sm"> + <Link + className="rounded px-1 underline decoration-primary decoration-dashed transition-colors duration-100 hover:bg-primary hover:text-primary-foreground" + href={`/novel?genres=${genre.slug}`} + > + {genre.name_ua} + </Link> + </span> + ))} + </div> + )} + </div> + ); +}; + +export default Title; diff --git a/features/people/person-view/cover.component.tsx b/features/people/person-view/cover.component.tsx index df1c7071..0c08a438 100644 --- a/features/people/person-view/cover.component.tsx +++ b/features/people/person-view/cover.component.tsx @@ -17,7 +17,7 @@ const Cover = () => { return ( <div className="flex items-center px-16 md:px-48 lg:px-0"> - <ContentCard poster={person.image} /> + <ContentCard image={person.image} /> </div> ); }; diff --git a/features/people/person-view/manga.component.tsx b/features/people/person-view/manga.component.tsx new file mode 100644 index 00000000..2a99ced9 --- /dev/null +++ b/features/people/person-view/manga.component.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import LoadMoreButton from '@/components/load-more-button'; +import MangaCard from '@/components/manga-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import usePersonManga from '@/services/hooks/people/use-person-manga'; + +interface Props { + extended?: boolean; +} + +const Manga: FC<Props> = ({ extended }) => { + const params = useParams(); + const { list, fetchNextPage, hasNextPage, isFetchingNextPage, ref } = + usePersonManga({ slug: String(params.slug) }); + + if (!list || list.length === 0) { + return null; + } + + return ( + <Block> + <Header + title={'Манґа'} + href={!extended ? params.slug + '/manga' : undefined} + /> + <Stack + size={5} + extendedSize={5} + extended={extended} + className="grid-min-10" + > + {(extended ? list : list.slice(0, 5)).map((ch) => ( + <MangaCard + key={ch.manga.slug} + manga={ch.manga} + description={ + ch.roles[0]?.name_ua || ch.roles[0]?.name_en + } + /> + ))} + </Stack> + {extended && hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + )} + </Block> + ); +}; + +export default Manga; diff --git a/features/people/person-view/novel.component.tsx b/features/people/person-view/novel.component.tsx new file mode 100644 index 00000000..467c5f50 --- /dev/null +++ b/features/people/person-view/novel.component.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import LoadMoreButton from '@/components/load-more-button'; +import NovelCard from '@/components/novel-card'; +import Block from '@/components/ui/block'; +import Header from '@/components/ui/header'; +import Stack from '@/components/ui/stack'; + +import usePersonNovel from '@/services/hooks/people/use-person-novel'; + +interface Props { + extended?: boolean; +} + +const Novel: FC<Props> = ({ extended }) => { + const params = useParams(); + const { list, fetchNextPage, hasNextPage, isFetchingNextPage, ref } = + usePersonNovel({ slug: String(params.slug) }); + + if (!list || list.length === 0) { + return null; + } + + return ( + <Block> + <Header + title={'Ранобе'} + href={!extended ? params.slug + '/novel' : undefined} + /> + <Stack + size={5} + extendedSize={5} + extended={extended} + className="grid-min-10" + > + {(extended ? list : list.slice(0, 5)).map((ch) => ( + <NovelCard + key={ch.novel.slug} + novel={ch.novel} + description={ + ch.roles[0]?.name_ua || ch.roles[0]?.name_en + } + /> + ))} + </Stack> + {extended && hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + )} + </Block> + ); +}; + +export default Novel; diff --git a/features/schedule/schedule-list/schedule-item.tsx b/features/schedule/schedule-list/schedule-item.tsx index 8fb4492c..ada3c71a 100644 --- a/features/schedule/schedule-list/schedule-item.tsx +++ b/features/schedule/schedule-list/schedule-item.tsx @@ -31,7 +31,7 @@ const ScheduleItem: FC<Props> = ({ item, ...props }) => { title={item.anime.title!} href={`/anime/${item.anime.slug}`} description={item.anime.synopsis_ua || item.anime.synopsis_en} - image={item.anime.poster} + image={item.anime.image} {...props} > <div className="flex w-full flex-col gap-4 sm:flex-row sm:items-end"> diff --git a/features/schedule/schedule-list/schedule-list.component.tsx b/features/schedule/schedule-list/schedule-list.component.tsx index 42c8f6e5..b1f953de 100644 --- a/features/schedule/schedule-list/schedule-list.component.tsx +++ b/features/schedule/schedule-list/schedule-list.component.tsx @@ -2,6 +2,7 @@ import { getUnixTime, startOfDay } from 'date-fns'; import format from 'date-fns/format'; +import { useSearchParams } from 'next/navigation'; import FiltersNotFound from '@/components/filters-not-found'; import LoadMoreButton from '@/components/load-more-button'; @@ -9,10 +10,25 @@ import Block from '@/components/ui/block'; import Header from '@/components/ui/header'; import useAnimeSchedule from '@/services/hooks/stats/use-anime-schedule'; +import getCurrentSeason from '@/utils/get-current-season'; import ScheduleItem from './schedule-item'; const ScheduleList = () => { + const searchParams = useSearchParams(); + + const only_watch = searchParams.get('only_watch') + ? Boolean(searchParams.get('only_watch')) + : undefined; + const season = + (searchParams.get('season') as API.Season) || getCurrentSeason()!; + const year = searchParams.get('year') || String(new Date().getFullYear()); + const status = ( + searchParams.getAll('status').length > 0 + ? searchParams.getAll('status') + : ['ongoing', 'announced'] + ) as API.Status[]; + const { list, hasNextPage, @@ -20,7 +36,11 @@ const ScheduleList = () => { fetchNextPage, isLoading, ref, - } = useAnimeSchedule(); + } = useAnimeSchedule({ + airing_season: [season, year], + status, + only_watch, + }); const sortedList = list?.reduce( (acc: Record<string, API.AnimeSchedule[]>, item) => { diff --git a/features/schedule/schedule-list/schedule-watch-button.tsx b/features/schedule/schedule-list/schedule-watch-button.tsx index 1a8a2394..f3abe906 100644 --- a/features/schedule/schedule-list/schedule-watch-button.tsx +++ b/features/schedule/schedule-list/schedule-watch-button.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'; import WatchEditModal from '@/features/modals/watch-edit-modal'; import useSession from '@/services/hooks/auth/use-session'; -import useAddToList from '@/services/hooks/watch/use-add-to-list'; +import useAddWatch from '@/services/hooks/watch/use-add-watch'; import { useModalContext } from '@/services/providers/modal-provider'; import { WATCH_STATUS } from '@/utils/constants'; @@ -21,7 +21,7 @@ const ScheduleWatchButton: FC<Props> = ({ item, title }) => { const { user: loggedUser } = useSession(); const { enqueueSnackbar } = useSnackbar(); const { openModal } = useModalContext(); - const { mutate: addToList } = useAddToList({ slug: item.anime.slug }); + const { mutate: addWatch } = useAddWatch(); const watch = item.anime.watch.length > 0 ? item.anime.watch[0] : null; const watchStatus = watch ? WATCH_STATUS[watch.status] : null; @@ -38,7 +38,7 @@ const ScheduleWatchButton: FC<Props> = ({ item, title }) => { return; } - addToList({ status: 'planned' }); + addWatch({ params: { status: 'planned', slug: item.anime.slug } }); enqueueSnackbar('Аніме додано до Вашого списку', { variant: 'success', diff --git a/features/users/follow-button.component.tsx b/features/users/follow-button.component.tsx index c5941f68..6fcf4031 100644 --- a/features/users/follow-button.component.tsx +++ b/features/users/follow-button.component.tsx @@ -95,8 +95,7 @@ const FollowButton: FC<Props> = ({ className }) => { openModal({ content: <AuthModal type="login" />, forceModal: true, - className: 'max-w-3xl', - containerClassName: 'p-0', + className: 'max-w-3xl p-0', }) } className={cn('w-fit', className)} diff --git a/features/users/list-stats.component.tsx b/features/users/list-stats.component.tsx deleted file mode 100644 index 4be4e9a7..00000000 --- a/features/users/list-stats.component.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import MaterialSymbolsOpenInNewRounded from '~icons/material-symbols/open-in-new-rounded'; - -import { Label } from '@/components/ui/label'; -import RadialProgress from '@/components/ui/radial-progress'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; - -import useWatchStats from '@/services/hooks/watch/use-watch-stats'; -import { WATCH_STATUS } from '@/utils/constants'; -import { cn } from '@/utils/utils'; - -const ListStats = () => { - const params = useParams(); - const { data } = useWatchStats({ username: String(params.username) }); - - if (!data) { - return null; - } - - const sum = Object.values({ ...data, duration: 0 }).reduce( - (acc, cur) => acc + cur, - 0, - ); - - return ( - <div className="flex flex-col gap-2"> - <Tabs value="anime" className="w-full overflow-hidden"> - <TabsList className="no-scrollbar w-full items-center justify-start border border-secondary/60 bg-secondary/80 backdrop-blur"> - <TabsTrigger value="anime" className="gap-2"> - Аніме{' '} - <Link href={`/u/${params.username}/list`}> - <MaterialSymbolsOpenInNewRounded /> - </Link> - </TabsTrigger> - </TabsList> - </Tabs> - <div className="flex h-fit w-full items-center gap-2 rounded-md border border-secondary/60 bg-secondary/30 p-2 backdrop-blur"> - <Link - href={`/u/${params.username}/list?status=completed`} - className="flex flex-col items-center gap-2 rounded-md p-2 hover:cursor-pointer hover:bg-secondary/60" - > - <RadialProgress - style={{ - color: WATCH_STATUS.completed.color, - }} - thickness={8} - value={(data.completed * 100) / sum} - > - {data.completed} - </RadialProgress> - <Label className="text-muted-foreground">Завершено</Label> - </Link> - <div className="no-scrollbar flex w-full flex-1 flex-col gap-0 overflow-x-scroll"> - {Object.keys(data).map((status) => { - if ( - status === 'completed' || - !(status in WATCH_STATUS) - ) { - return null; - } - - return ( - <Link - href={`/u/${params.username}/list?status=${status}`} - key={status} - className={cn( - 'rounded-md p-2 hover:cursor-pointer hover:bg-secondary/60', - )} - > - <div className="flex justify-between gap-4"> - <div className="flex min-w-0 items-center gap-2"> - <div - className="size-2 rounded-full bg-secondary" - style={{ - backgroundColor: - WATCH_STATUS[ - status as API.WatchStatus - ].color, - }} - /> - <Label className="cursor-pointer truncate text-muted-foreground"> - {WATCH_STATUS[ - status as API.WatchStatus - ].title_ua || - WATCH_STATUS[ - status as API.WatchStatus - ].title_en} - </Label> - </div> - <Label className="cursor-pointer"> - {data[status as API.WatchStatus]} - </Label> - </div> - </Link> - ); - })} - </div> - </div> - </div> - ); -}; - -export default ListStats; diff --git a/features/users/list-stats/list-stats.component.tsx b/features/users/list-stats/list-stats.component.tsx new file mode 100644 index 00000000..ee778c32 --- /dev/null +++ b/features/users/list-stats/list-stats.component.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { TabsContent } from '@radix-ui/react-tabs'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import MaterialSymbolsOpenInNewRounded from '~icons/material-symbols/open-in-new-rounded'; + +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import useWatchStats from '@/services/hooks/watch/use-watch-stats'; + +import ReadlistStats from './readlist-stats'; +import WatchlistStats from './watchlist-stats'; + +const ListStats = () => { + const params = useParams(); + const { data } = useWatchStats({ username: String(params.username) }); + + if (!data) { + return null; + } + + return ( + <Tabs + defaultValue="anime" + className="flex w-full flex-col gap-2 overflow-hidden" + > + <TabsList className="no-scrollbar w-full items-center justify-start border border-secondary/60 bg-secondary/80 backdrop-blur"> + <TabsTrigger value="anime" className="flex-1 gap-2"> + Аніме{' '} + <Link + href={`/u/${params.username}/list/anime?status=planned&sort=watch_score`} + > + <MaterialSymbolsOpenInNewRounded /> + </Link> + </TabsTrigger> + <TabsTrigger value="manga" className="flex-1 gap-2"> + Манга{' '} + <Link + href={`/u/${params.username}/list/manga?status=planned&sort=read_score`} + > + <MaterialSymbolsOpenInNewRounded /> + </Link> + </TabsTrigger> + <TabsTrigger value="novel" className="flex-1 gap-2"> + Ранобе{' '} + <Link + href={`/u/${params.username}/list/novel?status=planned&sort=read_score`} + > + <MaterialSymbolsOpenInNewRounded /> + </Link> + </TabsTrigger> + </TabsList> + <TabsContent value="anime"> + <WatchlistStats /> + </TabsContent> + <TabsContent value="manga"> + <ReadlistStats content_type="manga" /> + </TabsContent> + <TabsContent value="novel"> + <ReadlistStats content_type="novel" /> + </TabsContent> + </Tabs> + ); +}; + +export default ListStats; diff --git a/features/users/list-stats/readlist-stats.tsx b/features/users/list-stats/readlist-stats.tsx new file mode 100644 index 00000000..249e1914 --- /dev/null +++ b/features/users/list-stats/readlist-stats.tsx @@ -0,0 +1,96 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; +import RadialProgress from '@/components/ui/radial-progress'; + +import useReadStatus from '@/services/hooks/read/use-read-stats'; +import { READ_STATUS } from '@/utils/constants'; +import { cn } from '@/utils/utils'; + +interface Props { + content_type: 'manga' | 'novel'; +} + +const ReadlistStats: FC<Props> = ({ content_type }) => { + const params = useParams(); + const { data } = useReadStatus({ + username: String(params.username), + content_type, + }); + + if (!data) { + return null; + } + + const sum = Object.values({ ...data, duration: 0 }).reduce( + (acc, cur) => acc + cur, + 0, + ); + + return ( + <div className="flex h-fit w-full items-center gap-2 rounded-md border border-secondary/60 bg-secondary/30 p-2 backdrop-blur"> + <Link + href={`/u/${params.username}/list/${content_type}?status=completed&sort=read_score`} + className="flex flex-col items-center gap-2 rounded-md p-2 hover:cursor-pointer hover:bg-secondary/60" + > + <RadialProgress + style={{ + color: READ_STATUS.completed.color, + }} + thickness={8} + value={(data.completed * 100) / sum} + > + {data.completed} + </RadialProgress> + <Label className="text-muted-foreground">Завершено</Label> + </Link> + <div className="no-scrollbar flex w-full flex-1 flex-col gap-0 overflow-x-scroll"> + {Object.keys(data).map((status) => { + if (status === 'completed' || !(status in READ_STATUS)) { + return null; + } + + return ( + <Link + href={`/u/${params.username}/list/${content_type}?status=${status}&sort=read_score`} + key={status} + className={cn( + 'rounded-md p-2 hover:cursor-pointer hover:bg-secondary/60', + )} + > + <div className="flex justify-between gap-4"> + <div className="flex min-w-0 items-center gap-2"> + <div + className="size-2 rounded-full bg-secondary" + style={{ + backgroundColor: + READ_STATUS[ + status as API.ReadStatus + ].color, + }} + /> + <Label className="cursor-pointer truncate text-muted-foreground"> + {READ_STATUS[status as API.ReadStatus] + .title_ua || + READ_STATUS[ + status as API.ReadStatus + ].title_en} + </Label> + </div> + <Label className="cursor-pointer"> + {data[status as API.ReadStatus]} + </Label> + </div> + </Link> + ); + })} + </div> + </div> + ); +}; + +export default ReadlistStats; diff --git a/features/users/list-stats/watchlist-stats.tsx b/features/users/list-stats/watchlist-stats.tsx new file mode 100644 index 00000000..c3dc9616 --- /dev/null +++ b/features/users/list-stats/watchlist-stats.tsx @@ -0,0 +1,91 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import { Label } from '@/components/ui/label'; +import RadialProgress from '@/components/ui/radial-progress'; + +import useWatchStats from '@/services/hooks/watch/use-watch-stats'; +import { WATCH_STATUS } from '@/utils/constants'; +import { cn } from '@/utils/utils'; + +interface Props {} + +const WatchlistStats: FC<Props> = () => { + const params = useParams(); + const { data } = useWatchStats({ username: String(params.username) }); + + if (!data) { + return null; + } + + const sum = Object.values({ ...data, duration: 0 }).reduce( + (acc, cur) => acc + cur, + 0, + ); + + return ( + <div className="flex h-fit w-full items-center gap-2 rounded-md border border-secondary/60 bg-secondary/30 p-2 backdrop-blur"> + <Link + href={`/u/${params.username}/list/anime?status=completed&sort=watch_score`} + className="flex flex-col items-center gap-2 rounded-md p-2 hover:cursor-pointer hover:bg-secondary/60" + > + <RadialProgress + style={{ + color: WATCH_STATUS.completed.color, + }} + thickness={8} + value={(data.completed * 100) / sum} + > + {data.completed} + </RadialProgress> + <Label className="text-muted-foreground">Завершено</Label> + </Link> + <div className="no-scrollbar flex w-full flex-1 flex-col gap-0 overflow-x-scroll"> + {Object.keys(data).map((status) => { + if (status === 'completed' || !(status in WATCH_STATUS)) { + return null; + } + + return ( + <Link + href={`/u/${params.username}/list/anime?status=${status}&sort=watch_score`} + key={status} + className={cn( + 'rounded-md p-2 hover:cursor-pointer hover:bg-secondary/60', + )} + > + <div className="flex justify-between gap-4"> + <div className="flex min-w-0 items-center gap-2"> + <div + className="size-2 rounded-full bg-secondary" + style={{ + backgroundColor: + WATCH_STATUS[ + status as API.WatchStatus + ].color, + }} + /> + <Label className="cursor-pointer truncate text-muted-foreground"> + {WATCH_STATUS[status as API.WatchStatus] + .title_ua || + WATCH_STATUS[ + status as API.WatchStatus + ].title_en} + </Label> + </div> + <Label className="cursor-pointer"> + {data[status as API.WatchStatus]} + </Label> + </div> + </Link> + ); + })} + </div> + </div> + ); +}; + +export default WatchlistStats; diff --git a/features/users/user-profile/user-collections/collection-item.tsx b/features/users/user-profile/user-collections/collection-item.tsx index 287b89c1..6907b8c8 100644 --- a/features/users/user-profile/user-collections/collection-item.tsx +++ b/features/users/user-profile/user-collections/collection-item.tsx @@ -1,6 +1,8 @@ +import formatDistance from 'date-fns/formatDistance'; import { FC, Fragment, memo } from 'react'; import BxBxsUpvote from '~icons/bx/bxs-upvote'; import IconamoonCommentFill from '~icons/iconamoon/comment-fill'; +import MaterialSymbolsDriveFileRenameOutlineRounded from '~icons/material-symbols/drive-file-rename-outline-rounded'; import MaterialSymbolsGridViewRounded from '~icons/material-symbols/grid-view-rounded'; import Small from '@/components/typography/small'; @@ -14,8 +16,8 @@ interface Props { } const CollectionItem: FC<Props> = ({ data, className }) => { - const poster = (content: API.MainContent) => - content.data_type === 'anime' ? content.poster : content.image; + const image = (content: API.MainContent) => + content.data_type === 'anime' ? content.image : content.image; const Meta = ( <Fragment> @@ -31,6 +33,14 @@ const CollectionItem: FC<Props> = ({ data, className }) => { <BxBxsUpvote /> <Small>{data.vote_score}</Small> </div> + <div className="flex gap-1"> + <MaterialSymbolsDriveFileRenameOutlineRounded /> + <Small> + {formatDistance(data.updated * 1000, Date.now(), { + addSuffix: true, + })} + </Small> + </div> </Fragment> ); @@ -49,7 +59,7 @@ const CollectionItem: FC<Props> = ({ data, className }) => { title={data.title} href={`/collections/${data.reference}`} titleClassName={cn(data.spoiler && 'spoiler-blur-sm')} - image={poster(data.collection[0].content)} + image={image(data.collection[0].content)} imageClassName={cn(data.nsfw && 'spoiler-blur-sm')} description={data.description} descriptionClassName={cn(data.spoiler && 'spoiler-blur-sm')} diff --git a/features/users/user-profile/user-collections/collections-modal.tsx b/features/users/user-profile/user-collections/collections-modal.tsx index 56d3e7ab..4ead84e3 100644 --- a/features/users/user-profile/user-collections/collections-modal.tsx +++ b/features/users/user-profile/user-collections/collections-modal.tsx @@ -23,32 +23,30 @@ const CollectionModal: FC<Props> = ({ className }) => { ref, fetchNextPage, } = useUserCollections({ - username: String(params.username), + author: String(params.username), + sort: 'created', }); return ( - <> - <hr className="-mx-6 mt-4 h-px w-auto bg-border" /> - <div className="-mx-6 h-full w-auto flex-1 overflow-y-scroll"> - {collections && - collections.map((item) => ( - <CollectionItem - className="px-6 py-4" - data={item} - key={item.reference} - /> - ))} - {hasNextPage && ( - <div className="px-4"> - <LoadMoreButton - isFetchingNextPage={isFetchingNextPage} - fetchNextPage={fetchNextPage} - ref={ref} - /> - </div> - )} - </div> - </> + <div className="h-full w-auto flex-1 overflow-y-scroll"> + {collections && + collections.map((item) => ( + <CollectionItem + className="px-6 py-4" + data={item} + key={item.reference} + /> + ))} + {hasNextPage && ( + <div className="px-4"> + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + </div> + )} + </div> ); }; diff --git a/features/users/user-profile/user-collections/user-collections.component.tsx b/features/users/user-profile/user-collections/user-collections.component.tsx index 9aee9a7d..11f611d5 100644 --- a/features/users/user-profile/user-collections/user-collections.component.tsx +++ b/features/users/user-profile/user-collections/user-collections.component.tsx @@ -29,7 +29,8 @@ const UserCollections: FC<Props> = ({ className }) => { const { user: loggedUser } = useSession(); const { list: collections } = useUserCollections({ - username: String(params.username), + author: String(params.username), + sort: 'created', }); if (!collections) { diff --git a/features/users/user-profile/user-favorites/anime.tsx b/features/users/user-profile/user-favorites/anime.tsx index 923fad50..91a1253b 100644 --- a/features/users/user-profile/user-favorites/anime.tsx +++ b/features/users/user-profile/user-favorites/anime.tsx @@ -55,7 +55,7 @@ const Anime: FC<Props> = ({ extended }) => { res.watch.length > 0 ? res.watch[0] : undefined } title={res.title} - poster={res.poster} + image={res.image} href={`/anime/${res.slug}`} slug={res.slug} content_type="anime" diff --git a/features/users/user-profile/user-favorites/characters.tsx b/features/users/user-profile/user-favorites/characters.tsx index 39e999e8..2c7f2ba6 100644 --- a/features/users/user-profile/user-favorites/characters.tsx +++ b/features/users/user-profile/user-favorites/characters.tsx @@ -51,9 +51,9 @@ const Characters: FC<Props> = ({ extended }) => { <ContentCard key={res.slug} title={res.name_ua || res.name_en || res.name_ja} - poster={res.image} + image={res.image} href={`/characters/${res.slug}`} - content_type='character' + content_type="character" slug={res.slug} /> ))} diff --git a/features/users/user-profile/user-favorites/collections.tsx b/features/users/user-profile/user-favorites/collections.tsx index 6e6b2a1b..ded2e85f 100644 --- a/features/users/user-profile/user-favorites/collections.tsx +++ b/features/users/user-profile/user-favorites/collections.tsx @@ -37,8 +37,6 @@ const Collections: FC<Props> = ({ extended }) => { } const filteredData = (extended ? list : list?.slice(0, 6)) || []; - const poster = (content: API.MainContent) => - 'poster' in content ? content.poster : content.image; return ( <> @@ -54,7 +52,7 @@ const Collections: FC<Props> = ({ extended }) => { <ContentCard key={res.reference} title={res.title} - poster={poster(res.collection[0].content)} + image={res.collection[0].content.image} href={`/collections/${res.reference}`} titleClassName={cn( res.spoiler && 'blur hover:blur-none', diff --git a/features/users/user-profile/user-favorites/manga.tsx b/features/users/user-profile/user-favorites/manga.tsx new file mode 100644 index 00000000..c4e33743 --- /dev/null +++ b/features/users/user-profile/user-favorites/manga.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import ContentCard from '@/components/content-card/content-card'; +import LoadMoreButton from '@/components/load-more-button'; +import NotFound from '@/components/ui/not-found'; + +import useFavorites from '@/services/hooks/favorite/use-favorites'; +import { cn } from '@/utils/utils'; + +interface Props { + extended?: boolean; +} + +const Manga: FC<Props> = ({ extended }) => { + const params = useParams(); + const { + list, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + ref, + } = useFavorites<API.MangaInfo>({ + username: String(params.username), + content_type: 'manga', + }); + + if (isPending) { + return null; + } + + if (!list && !extended) { + return null; + } + + const filteredData = (extended ? list : list?.slice(0, 6)) || []; + + return ( + <> + {filteredData.length > 0 && ( + <div + className={cn( + 'grid grid-cols-2 gap-4 md:grid-cols-6 lg:gap-8', + !extended && + 'grid-min-10 no-scrollbar -mx-4 auto-cols-scroll grid-flow-col grid-cols-scroll overflow-x-auto px-4', + )} + > + {filteredData.map((res) => ( + <ContentCard + key={res.slug} + // read={res.read.length > 0 ? res.read[0] : undefined} + title={res.title} + image={res.image} + href={`/manga/${res.slug}`} + slug={res.slug} + content_type="manga" + /> + ))} + </div> + )} + {filteredData.length === 0 && ( + <NotFound + title={ + <span> + У списку <span className="font-black">Манґа</span>{' '} + пусто + </span> + } + description="Цей список оновиться після того як сюди буде додано аніме" + /> + )} + {extended && hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + )} + </> + ); +}; + +export default Manga; diff --git a/features/users/user-profile/user-favorites/novel.tsx b/features/users/user-profile/user-favorites/novel.tsx new file mode 100644 index 00000000..fe072585 --- /dev/null +++ b/features/users/user-profile/user-favorites/novel.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; + +import ContentCard from '@/components/content-card/content-card'; +import LoadMoreButton from '@/components/load-more-button'; +import NotFound from '@/components/ui/not-found'; + +import useFavorites from '@/services/hooks/favorite/use-favorites'; +import { cn } from '@/utils/utils'; + +interface Props { + extended?: boolean; +} + +const Novel: FC<Props> = ({ extended }) => { + const params = useParams(); + const { + list, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + ref, + } = useFavorites<API.NovelInfo>({ + username: String(params.username), + content_type: 'novel', + }); + + if (isPending) { + return null; + } + + if (!list && !extended) { + return null; + } + + const filteredData = (extended ? list : list?.slice(0, 6)) || []; + + return ( + <> + {filteredData.length > 0 && ( + <div + className={cn( + 'grid grid-cols-2 gap-4 md:grid-cols-6 lg:gap-8', + !extended && + 'grid-min-10 no-scrollbar -mx-4 auto-cols-scroll grid-flow-col grid-cols-scroll overflow-x-auto px-4', + )} + > + {filteredData.map((res) => ( + <ContentCard + key={res.slug} + read={res.read.length > 0 ? res.read[0] : undefined} + title={res.title} + image={res.image} + href={`/novel/${res.slug}`} + slug={res.slug} + content_type="novel" + /> + ))} + </div> + )} + {filteredData.length === 0 && ( + <NotFound + title={ + <span> + У списку <span className="font-black">Ранобе</span>{' '} + пусто + </span> + } + description="Цей список оновиться після того як сюди буде додано аніме" + /> + )} + {extended && hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + )} + </> + ); +}; + +export default Novel; diff --git a/features/users/user-profile/user-favorites/user-favorites.component.tsx b/features/users/user-profile/user-favorites/user-favorites.component.tsx index 08d8668a..bcd0cb81 100644 --- a/features/users/user-profile/user-favorites/user-favorites.component.tsx +++ b/features/users/user-profile/user-favorites/user-favorites.component.tsx @@ -10,6 +10,8 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import Anime from './anime'; import Character from './characters'; import Collections from './collections'; +import Manga from './manga'; +import Novel from './novel'; interface Props { extended?: boolean; @@ -25,6 +27,10 @@ const Favorites: FC<Props> = ({ extended }) => { return <Anime extended={extended} />; case 'character': return <Character extended={extended} />; + case 'manga': + return <Manga extended={extended} />; + case 'novel': + return <Novel extended={extended} />; case 'collection': return <Collections extended={extended} />; default: @@ -54,6 +60,12 @@ const Favorites: FC<Props> = ({ extended }) => { <ToggleGroupItem value="anime" aria-label="Улюблені аніме"> Аніме </ToggleGroupItem> + <ToggleGroupItem value="manga" aria-label="Улюблена манґа"> + Манґа + </ToggleGroupItem> + <ToggleGroupItem value="novel" aria-label="Улюблене ранобе"> + Ранобе + </ToggleGroupItem> <ToggleGroupItem value="character" aria-label="Улюблені персонажі" diff --git a/features/users/user-profile/user-history/history-modal.tsx b/features/users/user-profile/user-history/history-modal.tsx index 76e11bf8..3cf7aac7 100644 --- a/features/users/user-profile/user-history/history-modal.tsx +++ b/features/users/user-profile/user-history/history-modal.tsx @@ -20,27 +20,24 @@ const Component = ({ className }: Props) => { }); return ( - <> - <hr className="-mx-6 mt-4 h-px w-auto bg-border" /> - <div className="-mx-6 h-full w-auto flex-1 overflow-y-scroll"> - {list?.map((item) => ( - <HistoryItem - className="px-6 py-4" - data={item} - key={item.reference} + <div className="h-full w-auto flex-1 overflow-y-scroll"> + {list?.map((item) => ( + <HistoryItem + className="px-6 py-4" + data={item} + key={item.reference} + /> + ))} + {hasNextPage && ( + <div className="px-4"> + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} /> - ))} - {hasNextPage && ( - <div className="px-4"> - <LoadMoreButton - isFetchingNextPage={isFetchingNextPage} - fetchNextPage={fetchNextPage} - ref={ref} - /> - </div> - )} - </div> - </> + </div> + )} + </div> ); }; diff --git a/features/users/user-readlist/readlist/grid-view.tsx b/features/users/user-readlist/readlist/grid-view.tsx new file mode 100644 index 00000000..d1f46d2d --- /dev/null +++ b/features/users/user-readlist/readlist/grid-view.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FC } from 'react'; + +import MangaCard from '@/components/manga-card'; +import NovelCard from '@/components/novel-card'; + +interface Props { + data: API.Read[]; +} + +const GridView: FC<Props> = ({ data }) => { + return ( + <div className="grid grid-cols-2 gap-4 md:grid-cols-5 lg:gap-8"> + {data.map((res) => + res.content.data_type === 'manga' ? ( + <MangaCard manga={res.content} key={res.reference} /> + ) : ( + <NovelCard novel={res.content} key={res.reference} /> + ), + )} + </div> + ); +}; + +export default GridView; diff --git a/features/users/user-readlist/readlist/readlist.component.tsx b/features/users/user-readlist/readlist/readlist.component.tsx new file mode 100644 index 00000000..aca73a64 --- /dev/null +++ b/features/users/user-readlist/readlist/readlist.component.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; + +import LoadMoreButton from '@/components/load-more-button'; +import Block from '@/components/ui/block'; +import NotFound from '@/components/ui/not-found'; + +import useReadList from '@/services/hooks/read/use-read-list'; +import { READ_STATUS } from '@/utils/constants'; + +import GridView from './grid-view'; +import TableView from './table-view/table-view'; + +const List = () => { + const searchParams = useSearchParams()!; + const params = useParams(); + + const readStatus = searchParams.get('status'); + const view = searchParams.get('view') || 'table'; + + const media_type = searchParams.getAll('types'); + const status = searchParams.getAll('statuses'); + const years = searchParams.getAll('years'); + const genres = searchParams.getAll('genres'); + const magazines = searchParams.getAll('magazines'); + + const order = searchParams.get('order') || 'desc'; + const sort = searchParams.get('sort') || 'read_score'; + + const { list, fetchNextPage, isFetchingNextPage, hasNextPage, ref } = + useReadList({ + content_type: params.content_type as 'manga' | 'novel', + username: String(params.username), + read_status: String(readStatus) as API.ReadStatus, + media_type, + status, + years, + genres, + magazines, + sort: sort && order ? [`${sort}:${order}`] : undefined, + }); + + if (!list || !readStatus) { + return null; + } + + return ( + <Block> + {list.length > 0 ? ( + view === 'table' ? ( + <TableView data={list} /> + ) : ( + <GridView data={list} /> + ) + ) : ( + <NotFound + title={ + <span> + У списку{' '} + <span className="font-black"> + { + READ_STATUS[readStatus as API.ReadStatus] + .title_ua + } + </span>{' '} + пусто + </span> + } + description="Цей список оновиться після того як сюди буде додано аніме з цим статусом" + /> + )} + {hasNextPage && ( + <LoadMoreButton + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + ref={ref} + /> + )} + </Block> + ); +}; + +export default List; diff --git a/features/users/user-readlist/readlist/table-view/chapters-cell.tsx b/features/users/user-readlist/readlist/table-view/chapters-cell.tsx new file mode 100644 index 00000000..d71a0521 --- /dev/null +++ b/features/users/user-readlist/readlist/table-view/chapters-cell.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; + +import { TableCell } from '@/components/ui/table'; + +interface Props { + chapters: number; + total: number; +} + +const ChaptersCell: FC<Props> = ({ chapters, total }) => ( + <TableCell className="w-20 text-center" align="center"> + {chapters} / {total || '?'} + </TableCell> +); + +export default ChaptersCell; diff --git a/features/users/user-readlist/readlist/table-view/details-cell.tsx b/features/users/user-readlist/readlist/table-view/details-cell.tsx new file mode 100644 index 00000000..ed9d2568 --- /dev/null +++ b/features/users/user-readlist/readlist/table-view/details-cell.tsx @@ -0,0 +1,52 @@ +import Link from 'next/link'; +import { FC } from 'react'; + +import ContentCard from '@/components/content-card/content-card'; +import MDViewer from '@/components/markdown/viewer/MD-viewer'; +import TextExpand from '@/components/text-expand'; +import { Badge } from '@/components/ui/badge'; +import { TableCell } from '@/components/ui/table'; + +interface Props { + content: API.Manga | API.Novel; + content_type: 'manga' | 'novel'; + rereads: number; + note?: string; +} + +const DetailsCell: FC<Props> = ({ content, content_type, rereads, note }) => { + return ( + <TableCell className="w-36"> + <div className="flex items-center gap-4"> + <div className="hidden w-12 lg:block"> + <ContentCard + image={content.image} + href={`/${content_type}/${content.slug}`} + /> + </div> + <div className="flex flex-1 flex-col gap-2"> + <div className="flex items-center gap-2"> + <Link + className="line-clamp-2 hover:underline" + href={`/${content_type}/${content.slug}`} + > + {content.title} + </Link> + {rereads > 0 && ( + <Badge variant="outline">{rereads}</Badge> + )} + </div> + {note && ( + <TextExpand> + <MDViewer className="text-xs text-muted-foreground"> + {note} + </MDViewer> + </TextExpand> + )} + </div> + </div> + </TableCell> + ); +}; + +export default DetailsCell; diff --git a/features/users/user-readlist/readlist/table-view/number-cell.tsx b/features/users/user-readlist/readlist/table-view/number-cell.tsx new file mode 100644 index 00000000..e6fb59ef --- /dev/null +++ b/features/users/user-readlist/readlist/table-view/number-cell.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { FC } from 'react'; +import MaterialSymbolsMoreVert from '~icons/material-symbols/more-vert'; + +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { TableCell } from '@/components/ui/table'; + +import ReadEditModal from '@/features/modals/read-edit-modal'; + +import useSession from '@/services/hooks/auth/use-session'; +import { useModalContext } from '@/services/providers/modal-provider'; +import { cn } from '@/utils/utils'; + +interface Props { + number: number; + content: API.Manga | API.Novel; + content_type: 'manga' | 'novel'; +} + +const NumberCell: FC<Props> = ({ number, content, content_type }) => { + const params = useParams(); + const { openModal } = useModalContext(); + const { user: loggedUser } = useSession(); + + const openWatchEditModal = () => { + openModal({ + content: ( + <ReadEditModal + content_type={content_type} + slug={content.slug} + /> + ), + className: '!max-w-xl', + title: content.title, + forceModal: true, + }); + }; + + return ( + <TableCell className="w-12 pr-0"> + {loggedUser?.username === params.username && ( + <Button + size="icon-sm" + className="hidden group-hover:flex" + onClick={openWatchEditModal} + > + <MaterialSymbolsMoreVert /> + </Button> + )} + <Label + className={cn( + 'text-muted-foreground', + loggedUser?.username === params.username && + 'inline group-hover:hidden', + )} + > + {number} + </Label> + </TableCell> + ); +}; + +export default NumberCell; diff --git a/features/users/user-readlist/readlist/table-view/score-cell.tsx b/features/users/user-readlist/readlist/table-view/score-cell.tsx new file mode 100644 index 00000000..e66c2668 --- /dev/null +++ b/features/users/user-readlist/readlist/table-view/score-cell.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx'; +import { CSSProperties, FC } from 'react'; + +import { TableCell } from '@/components/ui/table'; + +interface Props { + score: number; +} + +const ScoreCell: FC<Props> = ({ score }) => ( + <TableCell className="w-4 text-right" align="right"> + <div + className={clsx( + 'radial-progress border border-secondary text-primary', + )} + style={ + { + '--value': score * 10, + '--size': '2.5rem', + '--thickness': score > 0 ? '2px' : '0px', + } as CSSProperties + } + role="progressbar" + > + {score} + </div> + </TableCell> +); + +export default ScoreCell; diff --git a/features/users/user-readlist/readlist/table-view/table-view.tsx b/features/users/user-readlist/readlist/table-view/table-view.tsx new file mode 100644 index 00000000..5fa79f47 --- /dev/null +++ b/features/users/user-readlist/readlist/table-view/table-view.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { + useParams, + usePathname, + useRouter, + useSearchParams, +} from 'next/navigation'; +import { FC } from 'react'; + +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +import createQueryString from '@/utils/create-query-string'; +import { cn } from '@/utils/utils'; + +import ChaptersCell from './chapters-cell'; +import DetailsCell from './details-cell'; +import NumberCell from './number-cell'; +import ScoreCell from './score-cell'; +import VolumesCell from './volumes-cell'; + +interface Props { + data: API.Read[]; +} + +const TableView: FC<Props> = ({ data }) => { + const params = useParams(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + + const order = searchParams.get('order'); + const sort = searchParams.get('sort'); + + const switchSort = ( + newSort: 'watch_score' | 'watch_episodes' | 'media_type', + ) => { + const query = createQueryString( + 'order', + order && newSort !== sort + ? order + : order === 'asc' + ? 'desc' + : 'asc', + createQueryString( + 'sort', + newSort, + new URLSearchParams(searchParams), + ), + ).toString(); + + router.replace(`${pathname}?${query}`); + }; + + return ( + <Table className="table"> + <TableHeader className="overflow-hidden rounded-lg bg-secondary/30 backdrop-blur [&_tr]:border-b-0"> + <TableRow> + <TableHead>#</TableHead> + <TableHead>Деталі</TableHead> + <TableHead + className={cn( + 'cursor-pointer select-none text-center hover:underline', + sort === 'watch_episodes' && 'text-primary', + )} + align="center" + onClick={() => switchSort('watch_episodes')} + > + Розділи + </TableHead> + <TableHead + className={cn( + 'hidden w-20 cursor-pointer select-none text-center hover:underline lg:table-cell', + sort === 'media_type' && 'text-primary', + )} + align="center" + onClick={() => switchSort('media_type')} + > + Томи + </TableHead> + <TableHead + className={cn( + 'w-4 cursor-pointer select-none text-right hover:underline', + sort === 'watch_score' && 'text-primary', + )} + align="right" + onClick={() => switchSort('watch_score')} + > + Оцінка + </TableHead> + </TableRow> + </TableHeader> + <TableBody> + {data.map((res, i) => ( + <TableRow key={res.reference} className="group"> + <NumberCell + content_type={ + params.content_type as 'manga' | 'novel' + } + content={res.content} + number={i + 1} + /> + <DetailsCell + note={res.note} + content_type={ + params.content_type as 'manga' | 'novel' + } + content={res.content} + rereads={res.rereads} + /> + <ChaptersCell + chapters={res.chapters} + total={res.content.chapters} + /> + <VolumesCell + volumes={res.volumes} + total={res.content.volumes} + /> + <ScoreCell score={res.score} /> + </TableRow> + ))} + </TableBody> + </Table> + ); +}; + +export default TableView; diff --git a/features/users/user-readlist/readlist/table-view/volumes-cell.tsx b/features/users/user-readlist/readlist/table-view/volumes-cell.tsx new file mode 100644 index 00000000..ea6f019b --- /dev/null +++ b/features/users/user-readlist/readlist/table-view/volumes-cell.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; + +import { TableCell } from '@/components/ui/table'; + +interface Props { + volumes: number; + total: number; +} + +const VolumesCell: FC<Props> = ({ volumes, total }) => ( + <TableCell className="w-20 text-center" align="center"> + {volumes} / {total || '?'} + </TableCell> +); + +export default VolumesCell; diff --git a/features/users/user-readlist/status-combobox.component.tsx b/features/users/user-readlist/status-combobox.component.tsx new file mode 100644 index 00000000..40a36c20 --- /dev/null +++ b/features/users/user-readlist/status-combobox.component.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { + useParams, + usePathname, + useRouter, + useSearchParams, +} from 'next/navigation'; +import { createElement } from 'react'; + +import H5 from '@/components/typography/h5'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectGroup, + SelectIcon, + SelectItem, + SelectList, + SelectTrigger, +} from '@/components/ui/select'; + +import useReadList from '@/services/hooks/read/use-read-list'; +import { READ_STATUS } from '@/utils/constants'; +import createQueryString from '@/utils/create-query-string'; + +const StatusCombobox = () => { + const searchParams = useSearchParams(); + const pathname = usePathname(); + const params = useParams(); + const router = useRouter(); + + const readStatus = searchParams.get('status')! as API.ReadStatus; + + const { pagination } = useReadList({ + content_type: params.content_type as 'manga' | 'novel', + username: String(params.username), + read_status: String(readStatus) as API.ReadStatus, + }); + + const handleWatchStatusChange = (value: string[]) => { + { + const query = createQueryString( + 'status', + value[0], + new URLSearchParams(searchParams), + ); + router.replace(`${pathname}?${query}`); + } + }; + + return ( + <Select value={[readStatus]} onValueChange={handleWatchStatusChange}> + <SelectTrigger> + <div className="flex items-center gap-4"> + <div className="hidden rounded-md border border-secondary bg-secondary/60 p-1 sm:block"> + {createElement(READ_STATUS[readStatus].icon!)} + </div> + <div className="flex items-center gap-2"> + <H5>{READ_STATUS[readStatus].title_ua}</H5> + {pagination && ( + <Label className="text-muted-foreground"> + ({pagination.total}) + </Label> + )} + </div> + </div> + <SelectIcon /> + </SelectTrigger> + <SelectContent> + <SelectList> + <SelectGroup> + {(Object.keys(READ_STATUS) as API.ReadStatus[]).map( + (o) => ( + <SelectItem key={o} value={o}> + {READ_STATUS[o].title_ua} + </SelectItem> + ), + )} + </SelectGroup> + </SelectList> + </SelectContent> + </Select> + ); +}; + +export default StatusCombobox; diff --git a/features/users/user-readlist/tools-combobox.component.tsx b/features/users/user-readlist/tools-combobox.component.tsx new file mode 100644 index 00000000..7691da37 --- /dev/null +++ b/features/users/user-readlist/tools-combobox.component.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useMutation } from '@tanstack/react-query'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import AntDesignFilterFilled from '~icons/ant-design/filter-filled'; +import FeRandom from '~icons/fe/random'; +import MaterialSymbolsMoreVert from '~icons/material-symbols/more-vert'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +import ReadFiltersModal from '@/features/modals/read-filters-modal'; + +import getRandomWatch from '@/services/api/watch/getRandomWatch'; + +const ToolsCombobox = () => { + const searchParams = useSearchParams(); + const params = useParams(); + const router = useRouter(); + + const watchStatus = searchParams.get('status')!; + + const mutation = useMutation({ + mutationFn: getRandomWatch, + onSuccess: (data) => { + router.push('/anime/' + data.slug); + }, + }); + + const handleRandomAnime = async () => { + mutation.mutate({ + params: { + username: String(params.username), + status: watchStatus as API.WatchStatus, + }, + }); + }; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon-sm"> + <MaterialSymbolsMoreVert /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={handleRandomAnime}> + <FeRandom className="mr-2 size-4" /> Випадкове аніме + </DropdownMenuItem> + <ReadFiltersModal + sort_type="read" + content_type={params.content_type as API.ContentType} + > + <DropdownMenuItem + className="flex lg:hidden" + onSelect={(e) => e.preventDefault()} + > + <AntDesignFilterFilled className="mr-2 size-4" />{' '} + Фільтри + </DropdownMenuItem> + </ReadFiltersModal> + </DropdownMenuContent> + </DropdownMenu> + ); +}; + +export default ToolsCombobox; diff --git a/features/users/user-readlist/view-combobox.component.tsx b/features/users/user-readlist/view-combobox.component.tsx new file mode 100644 index 00000000..a14f4fe3 --- /dev/null +++ b/features/users/user-readlist/view-combobox.component.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import IcRoundGridView from '~icons/ic/round-grid-view'; +import MaterialSymbolsEventList from '~icons/material-symbols/event-list'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectList, + SelectTrigger, +} from '@/components/ui/select'; + +import createQueryString from '@/utils/create-query-string'; + +type View = 'table' | 'grid'; + +const ViewCombobox = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const view = (searchParams.get('view') || 'table') as View; + + const handleChangeView = (value: string[]) => { + const query = createQueryString( + 'view', + value[0], + new URLSearchParams(searchParams), + ); + router.replace(`${pathname}?${query}`); + }; + + return ( + <Select value={[view]} onValueChange={handleChangeView}> + <SelectTrigger asChild> + <Button variant="ghost" size="icon-sm"> + {view === 'table' ? ( + <MaterialSymbolsEventList /> + ) : ( + <IcRoundGridView /> + )} + </Button> + </SelectTrigger> + <SelectContent> + <SelectList> + <SelectGroup> + <SelectItem value="table"> + <div className="flex items-center gap-2"> + <MaterialSymbolsEventList /> Таблиця + </div> + </SelectItem> + <SelectItem value="grid"> + <div className="flex items-center gap-2"> + <IcRoundGridView /> Сітка + </div> + </SelectItem> + </SelectGroup> + </SelectList> + </SelectContent> + </Select> + ); +}; + +export default ViewCombobox; diff --git a/features/users/user-watchlist/tools-combobox.component.tsx b/features/users/user-watchlist/tools-combobox.component.tsx index 047b9ad3..335dcc5e 100644 --- a/features/users/user-watchlist/tools-combobox.component.tsx +++ b/features/users/user-watchlist/tools-combobox.component.tsx @@ -14,7 +14,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import FiltersModal from '@/features/modals/anime-filters-modal'; +import AnimeFiltersModal from '@/features/modals/anime-filters-modal'; import getRandomWatch from '@/services/api/watch/getRandomWatch'; @@ -52,7 +52,7 @@ const ToolsCombobox = () => { <DropdownMenuItem onClick={handleRandomAnime}> <FeRandom className="mr-2 size-4" /> Випадкове аніме </DropdownMenuItem> - <FiltersModal type={'watchlist'}> + <AnimeFiltersModal sort_type="watch"> <DropdownMenuItem className="flex lg:hidden" onSelect={(e) => e.preventDefault()} @@ -60,7 +60,7 @@ const ToolsCombobox = () => { <AntDesignFilterFilled className="mr-2 size-4" />{' '} Фільтри </DropdownMenuItem> - </FiltersModal> + </AnimeFiltersModal> </DropdownMenuContent> </DropdownMenu> ); diff --git a/features/users/user-watchlist/watchlist/table-view/details-cell.tsx b/features/users/user-watchlist/watchlist/table-view/details-cell.tsx index 64fde3c0..a2c0a8ef 100644 --- a/features/users/user-watchlist/watchlist/table-view/details-cell.tsx +++ b/features/users/user-watchlist/watchlist/table-view/details-cell.tsx @@ -19,7 +19,7 @@ const DetailsCell: FC<Props> = ({ anime, rewatches, note }) => { <div className="flex items-center gap-4"> <div className="hidden w-12 lg:block"> <ContentCard - poster={anime.poster} + image={anime.image} href={`/anime/${anime.slug}`} /> </div> diff --git a/features/users/user-watchlist/watchlist/table-view/media-cell.tsx b/features/users/user-watchlist/watchlist/table-view/media-cell.tsx index 011b0499..f392b4a1 100644 --- a/features/users/user-watchlist/watchlist/table-view/media-cell.tsx +++ b/features/users/user-watchlist/watchlist/table-view/media-cell.tsx @@ -2,15 +2,15 @@ import { FC } from 'react'; import { TableCell } from '@/components/ui/table'; -import { MEDIA_TYPE } from '@/utils/constants'; +import { ANIME_MEDIA_TYPE } from '@/utils/constants'; interface Props { - media_type: API.MediaType; + media_type: API.AnimeMediaType; } const MediaCell: FC<Props> = ({ media_type }) => ( <TableCell className="hidden w-20 lg:table-cell" align="center"> - {MEDIA_TYPE[media_type as API.MediaType]?.title_ua || '-'} + {ANIME_MEDIA_TYPE[media_type]?.title_ua || '-'} </TableCell> ); diff --git a/features/users/user-watchlist/watchlist/watchlist.component.tsx b/features/users/user-watchlist/watchlist/watchlist.component.tsx index f24c149d..e99432f1 100644 --- a/features/users/user-watchlist/watchlist/watchlist.component.tsx +++ b/features/users/user-watchlist/watchlist/watchlist.component.tsx @@ -19,10 +19,29 @@ const List = () => { const watchStatus = searchParams.get('status'); const view = searchParams.get('view') || 'table'; + const media_type = searchParams.getAll('types'); + const status = searchParams.getAll('statuses'); + const season = searchParams.getAll('seasons'); + const rating = searchParams.getAll('ratings'); + const years = searchParams.getAll('years'); + const genres = searchParams.getAll('genres'); + const studios = searchParams.getAll('studios'); + + const order = searchParams.get('order') || 'desc'; + const sort = searchParams.get('sort') || 'watch_score'; + const { list, fetchNextPage, isFetchingNextPage, hasNextPage, ref } = useWatchList({ username: String(params.username), watch_status: String(watchStatus) as API.WatchStatus, + media_type, + status, + season, + rating, + years, + genres, + studios, + sort: sort && order ? [`${sort}:${order}`] : undefined, }); if (!list || !watchStatus) { diff --git a/next.config.mjs b/next.config.mjs index 71f360ea..3b6d82d7 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -36,6 +36,15 @@ const nextConfig = { }, ]; }, + async redirects() { + return [ + { + source: '/u/:username/list', + destination: '/u/:username/list/anime', + permanent: true, + }, + ]; + }, }; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true', diff --git a/package.json b/package.json index 47dbf683..bb40d422 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@udecode/plate-basic-marks": "^34.0.0", "@udecode/plate-break": "^34.0.0", "@udecode/plate-common": "^34.0.1", + "@udecode/plate-diff": "^34.0.0", "@udecode/plate-link": "^34.0.1", "@udecode/plate-list": "^34.0.0", "@udecode/plate-paragraph": "^34.0.0", @@ -72,7 +73,7 @@ "i18next": "^23.11.2", "lucide-react": "^0.309.0", "marked": "^12.0.1", - "next": "14.2.3", + "next": "14.2.4", "next-nprogress-bar": "^2.3.9", "next-plausible": "^3.12.0", "next-themes": "^0.2.1", diff --git a/services/api/anime/getAnimeCatalog.tsx b/services/api/anime/getAnimeCatalog.tsx index dde45354..9fc2ba2c 100644 --- a/services/api/anime/getAnimeCatalog.tsx +++ b/services/api/anime/getAnimeCatalog.tsx @@ -1,6 +1,5 @@ import { BaseFetchRequestProps, - FetchRequestProps, fetchRequest, } from '@/services/api/fetchRequest'; @@ -8,7 +7,7 @@ export interface Params { query?: string | null; sort?: string[]; years?: string[]; - score?: string[]; + score?: number[]; media_type?: string[]; rating?: string[]; status?: string[]; diff --git a/services/api/characters/getCharacterManga.tsx b/services/api/characters/getCharacterManga.tsx new file mode 100644 index 00000000..f88cddb0 --- /dev/null +++ b/services/api/characters/getCharacterManga.tsx @@ -0,0 +1,30 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.WithPagination<CharacterManga> {} + +export type CharacterManga = { + main: boolean; + manga: API.Manga; +}; + +export interface Params { + slug: string; +} + +export default async function req({ + params, + page = 1, + size = 15, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/characters/${params?.slug}/manga`, + method: 'get', + page, + size, + }); +} diff --git a/services/api/characters/getCharacterNovel.tsx b/services/api/characters/getCharacterNovel.tsx new file mode 100644 index 00000000..9d718d78 --- /dev/null +++ b/services/api/characters/getCharacterNovel.tsx @@ -0,0 +1,30 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.WithPagination<CharacterNovel> {} + +export type CharacterNovel = { + main: boolean; + novel: API.Novel; +}; + +export interface Params { + slug: string; +} + +export default async function req({ + params, + page = 1, + size = 15, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/characters/${params?.slug}/novel`, + method: 'get', + page, + size, + }); +} diff --git a/services/api/comments/getGlobalComments.ts b/services/api/comments/getGlobalComments.ts index 70a3cd8d..f0d69237 100644 --- a/services/api/comments/getGlobalComments.ts +++ b/services/api/comments/getGlobalComments.ts @@ -7,10 +7,10 @@ export default async function req({ page = 1, size = 15, ...props -}: BaseFetchRequestProps): Promise<API.WithPagination<API.GlobalComment>> { - return fetchRequest<API.WithPagination<API.GlobalComment>>({ +}: BaseFetchRequestProps): Promise<API.WithPagination<API.Comment>> { + return fetchRequest<API.WithPagination<API.Comment>>({ ...props, - path: `/comments/list`, + path: `/comments/list/new`, method: 'get', page, size, diff --git a/services/api/comments/getLatestComments.ts b/services/api/comments/getLatestComments.ts index afe73044..0ce0a989 100644 --- a/services/api/comments/getLatestComments.ts +++ b/services/api/comments/getLatestComments.ts @@ -5,10 +5,10 @@ import { export default async function req( props?: BaseFetchRequestProps, -): Promise<API.GlobalComment[]> { - return fetchRequest<API.GlobalComment[]>({ +): Promise<API.Comment[]> { + return fetchRequest<API.Comment[]>({ ...props, - path: `/comments/latest`, + path: `/comments/latest/new`, method: 'get', }); } diff --git a/services/api/favourite/getFavouriteList.ts b/services/api/favourite/getFavouriteList.ts index 67e03074..275f4b21 100644 --- a/services/api/favourite/getFavouriteList.ts +++ b/services/api/favourite/getFavouriteList.ts @@ -1,15 +1,14 @@ import { BaseFetchRequestProps, - FetchRequestProps, fetchRequest, } from '@/services/api/fetchRequest'; +export type FavoriteContent<TContent = API.Content> = { + favourite_created: number; +} & TContent; + export interface Response<TContent = API.Content> - extends API.WithPagination< - { - favourite_created: number; - } & TContent - > {} + extends API.WithPagination<FavoriteContent<TContent>> {} export interface Params { username: string; diff --git a/services/api/anime/getAnimeGenres.tsx b/services/api/genres/getGenres.ts similarity index 90% rename from services/api/anime/getAnimeGenres.tsx rename to services/api/genres/getGenres.ts index d2c90b29..4e15d425 100644 --- a/services/api/anime/getAnimeGenres.tsx +++ b/services/api/genres/getGenres.ts @@ -6,7 +6,7 @@ interface Response { export default async function req(): Promise<Response> { return fetchRequest<Response>({ - path: `/anime/genres`, + path: `/genres`, method: 'get', config: { cache: 'force-cache', diff --git a/services/api/manga/getMangaCatalog.tsx b/services/api/manga/getMangaCatalog.tsx new file mode 100644 index 00000000..1f45a64c --- /dev/null +++ b/services/api/manga/getMangaCatalog.tsx @@ -0,0 +1,34 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Params { + years?: string[]; + media_type?: string[]; + status?: string[]; + only_translated?: boolean; + magazines?: string[]; + genres?: string[]; + score?: string[]; + query?: string | null; + sort?: string[]; + size?: number; +} + +export interface Response extends API.WithPagination<API.Manga> {} + +export default async function req( + props: BaseFetchRequestProps<Params>, +): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: '/manga', + method: 'post', + config: { + next: { + revalidate: 3600, + }, + }, + }); +} diff --git a/services/api/manga/getMangaCharacters.tsx b/services/api/manga/getMangaCharacters.tsx new file mode 100644 index 00000000..ddd65d12 --- /dev/null +++ b/services/api/manga/getMangaCharacters.tsx @@ -0,0 +1,26 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.WithPagination<MangaCharacter> {} + +export interface Params { + slug: string; +} + +export type MangaCharacter = { + main: boolean; + character: API.Character; +}; + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/manga/${params?.slug}/characters`, + method: 'get', + }); +} diff --git a/services/api/manga/getMangaInfo.tsx b/services/api/manga/getMangaInfo.tsx new file mode 100644 index 00000000..06a347e5 --- /dev/null +++ b/services/api/manga/getMangaInfo.tsx @@ -0,0 +1,21 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.MangaInfo {} + +export interface Params { + slug: string; +} + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/manga/${params?.slug}`, + method: 'get', + }); +} diff --git a/services/api/novel/getNovelCatalog.tsx b/services/api/novel/getNovelCatalog.tsx new file mode 100644 index 00000000..4028205b --- /dev/null +++ b/services/api/novel/getNovelCatalog.tsx @@ -0,0 +1,34 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Params { + years?: string[]; + media_type?: string[]; + status?: string[]; + only_translated?: boolean; + magazines?: string[]; + genres?: string[]; + score?: string[]; + query?: string | null; + sort?: string[]; + size?: number; +} + +export interface Response extends API.WithPagination<API.Novel> {} + +export default async function req( + props: BaseFetchRequestProps<Params>, +): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: '/novel', + method: 'post', + config: { + next: { + revalidate: 3600, + }, + }, + }); +} diff --git a/services/api/novel/getNovelCharacters.tsx b/services/api/novel/getNovelCharacters.tsx new file mode 100644 index 00000000..f451c606 --- /dev/null +++ b/services/api/novel/getNovelCharacters.tsx @@ -0,0 +1,26 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.WithPagination<NovelCharacter> {} + +export interface Params { + slug: string; +} + +export type NovelCharacter = { + main: boolean; + character: API.Character; +}; + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/novel/${params?.slug}/characters`, + method: 'get', + }); +} diff --git a/services/api/novel/getNovelInfo.tsx b/services/api/novel/getNovelInfo.tsx new file mode 100644 index 00000000..6ea9eb0e --- /dev/null +++ b/services/api/novel/getNovelInfo.tsx @@ -0,0 +1,21 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.NovelInfo {} + +export interface Params { + slug: string; +} + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/novel/${params?.slug}`, + method: 'get', + }); +} diff --git a/services/api/people/getPersonManga.tsx b/services/api/people/getPersonManga.tsx new file mode 100644 index 00000000..22b83792 --- /dev/null +++ b/services/api/people/getPersonManga.tsx @@ -0,0 +1,34 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.WithPagination<PersonManga> {} + +export type PersonManga = { + roles: { + name_en: string; + name_ua: string; + }[]; + manga: API.MangaInfo; +}; + +export interface Params { + slug: string; + size?: number; +} + +export default async function req({ + params, + page = 1, + size = 15, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/people/${params?.slug}/manga`, + method: 'get', + page, + size, + }); +} diff --git a/services/api/people/getPersonNovel.tsx b/services/api/people/getPersonNovel.tsx new file mode 100644 index 00000000..bc447779 --- /dev/null +++ b/services/api/people/getPersonNovel.tsx @@ -0,0 +1,34 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.WithPagination<PersonNovel> {} + +export type PersonNovel = { + roles: { + name_en: string; + name_ua: string; + }[]; + novel: API.NovelInfo; +}; + +export interface Params { + slug: string; + size?: number; +} + +export default async function req({ + params, + page = 1, + size = 15, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/people/${params?.slug}/novel`, + method: 'get', + page, + size, + }); +} diff --git a/services/api/read/addRead.ts b/services/api/read/addRead.ts new file mode 100644 index 00000000..cbeb3776 --- /dev/null +++ b/services/api/read/addRead.ts @@ -0,0 +1,31 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.Read {} + +export interface Params { + slug: string; + content_type: 'manga' | 'novel'; + note?: string | null; + chapters?: number | null; + volumes?: number | null; + rereads?: number | null; + score?: number | null; + status: API.ReadStatus; +} + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + const { slug, content_type, ...restParams } = params!; + + return fetchRequest<Response>({ + ...props, + path: `/read/${content_type}/${slug}`, + method: 'put', + params: restParams, + }); +} diff --git a/services/api/read/deleteRead.ts b/services/api/read/deleteRead.ts new file mode 100644 index 00000000..fd30fd04 --- /dev/null +++ b/services/api/read/deleteRead.ts @@ -0,0 +1,24 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response { + success: boolean; +} + +export interface Params { + content_type: 'manga' | 'novel'; + slug: string; +} + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/read/${params?.content_type}/${params?.slug}`, + method: 'delete', + }); +} diff --git a/services/api/read/getFollowingReadList.ts b/services/api/read/getFollowingReadList.ts new file mode 100644 index 00000000..73029e41 --- /dev/null +++ b/services/api/read/getFollowingReadList.ts @@ -0,0 +1,31 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response + extends API.WithPagination< + { + read: API.Read[]; + } & API.User + > {} + +export interface Params { + slug: string; + content_type: 'manga' | 'novel'; +} + +export default async function req({ + params, + page = 1, + size = 15, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/read/${params?.slug}/${params?.content_type}/following`, + method: 'get', + page, + size, + }); +} diff --git a/services/api/read/getRandomRead.ts b/services/api/read/getRandomRead.ts new file mode 100644 index 00000000..b02ffeb8 --- /dev/null +++ b/services/api/read/getRandomRead.ts @@ -0,0 +1,23 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.Manga {} + +export interface Params { + username: string; + status: API.ReadStatus; + content_type: 'manga' | 'novel'; +} + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/read/${params?.content_type}/random/${params?.username}/${params?.status}`, + method: 'get', + }); +} diff --git a/services/api/read/getRead.ts b/services/api/read/getRead.ts new file mode 100644 index 00000000..ce657f6b --- /dev/null +++ b/services/api/read/getRead.ts @@ -0,0 +1,22 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.Read {} + +export interface Params { + slug: string; + content_type: 'manga' | 'novel'; +} + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/read/${params?.content_type}/${params?.slug}`, + method: 'get', + }); +} diff --git a/services/api/read/getReadList.ts b/services/api/read/getReadList.ts new file mode 100644 index 00000000..91b910f2 --- /dev/null +++ b/services/api/read/getReadList.ts @@ -0,0 +1,34 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.WithPagination<API.Read> {} + +export interface Params { + years?: string[]; + media_type?: string[]; + status?: string[]; + only_translated?: boolean; + magazines?: string[]; + genres?: string[]; + score?: string[]; + sort?: string[]; + read_status: API.ReadStatus; + username: string; + content_type: 'manga' | 'novel'; +} + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + const { username, content_type, ...restParams } = params!; + + return fetchRequest<Response>({ + ...props, + path: `/read/${content_type}/${username}/list`, + method: 'post', + params: restParams, + }); +} diff --git a/services/api/read/getReadStats.ts b/services/api/read/getReadStats.ts new file mode 100644 index 00000000..964cbbef --- /dev/null +++ b/services/api/read/getReadStats.ts @@ -0,0 +1,20 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Params { + username: string; + content_type: 'manga' | 'novel'; +} + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Record<API.ReadStatus, number>> { + return fetchRequest<Record<API.ReadStatus, number>>({ + ...props, + path: `/read/${params?.content_type}/${params?.username}/stats`, + method: 'get', + }); +} diff --git a/services/api/related/getFranchise.ts b/services/api/related/getFranchise.ts new file mode 100644 index 00000000..35d29f47 --- /dev/null +++ b/services/api/related/getFranchise.ts @@ -0,0 +1,28 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response { + anime: API.Anime[]; + manga: API.Manga[]; + novel: API.Novel[]; +} + +export interface Params { + slug: string; + content_type: API.ContentType; +} + +export default async function req({ + params, + ...props +}: BaseFetchRequestProps<Params>): Promise<Response> { + const { content_type, slug } = params!; + + return fetchRequest<Response>({ + ...props, + path: `/related/${content_type}/${slug}/franchise`, + method: 'get', + }); +} diff --git a/services/api/settings/deleteUserImage.tsx b/services/api/settings/deleteUserImage.tsx new file mode 100644 index 00000000..ce98a541 --- /dev/null +++ b/services/api/settings/deleteUserImage.tsx @@ -0,0 +1,22 @@ +// +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response extends API.User {} + +export interface Params { + image_type: 'avatar' | 'cover'; +} + +export default async function req({ + params, +}: BaseFetchRequestProps<Params>): Promise<Response> { + const { image_type } = params!; + + return fetchRequest<Response>({ + path: `/settings/image/${image_type}`, + method: 'delete', + }); +} diff --git a/services/api/settings/importRead.tsx b/services/api/settings/importRead.tsx new file mode 100644 index 00000000..6fe19877 --- /dev/null +++ b/services/api/settings/importRead.tsx @@ -0,0 +1,23 @@ +import { + BaseFetchRequestProps, + fetchRequest, +} from '@/services/api/fetchRequest'; + +export interface Response { + success: boolean; +} + +export interface Params { + overwrite: boolean; + content: Record<string, any>[]; +} + +export default async function req( + props: BaseFetchRequestProps<Params>, +): Promise<Response> { + return fetchRequest<Response>({ + ...props, + path: `/settings/import/read`, + method: 'post', + }); +} diff --git a/services/hooks/anime/use-anime-catalog.ts b/services/hooks/anime/use-anime-catalog.ts index b8315b32..769c9b23 100644 --- a/services/hooks/anime/use-anime-catalog.ts +++ b/services/hooks/anime/use-anime-catalog.ts @@ -1,74 +1,54 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; -import { useSearchParams } from 'next/navigation'; +import { QueryKey, useInfiniteQuery } from '@tanstack/react-query'; import getAnimeCatalog, { Params as AnimeCatalogParams, Response as AnimeCatalogResponse, } from '@/services/api/anime/getAnimeCatalog'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnimeList } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitleList } from '@/utils/title-adapter'; export interface Props extends AnimeCatalogParams { page: number; iPage: number; } -const useAnimeCatalog = ({ page, iPage, ...props }: Props) => { +export const paramsBuilder = ( + props: Omit<Props, 'iPage'>, +): Omit<Props, 'iPage'> => ({ + page: props.page || 1, + query: props.query || undefined, + media_type: props.media_type || [], + status: props.status || [], + season: props.season || [], + rating: props.rating || [], + years: props.years || [], + only_translated: props.only_translated || undefined, + genres: props.genres || [], + studios: props.studios || [], + sort: props.sort || ['score:desc'], +}); + +export const key = (params: Omit<Props, 'iPage'>): QueryKey => [ + 'anime-list', + params, +]; + +const useAnimeCatalog = (props: Props) => { const { titleLanguage } = useSettingsContext(); - const searchParams = useSearchParams(); - const search = props.query || searchParams.get('search'); - const types = props.media_type || searchParams.getAll('types'); - const statuses = props.status || searchParams.getAll('statuses'); - const seasons = props.season || searchParams.getAll('seasons'); - const ageRatings = props.rating || searchParams.getAll('ratings'); - const years = props.years || searchParams.getAll('years'); - const genres = props.genres || searchParams.getAll('genres'); - const studios = props.studios || searchParams.getAll('studios'); - const lang = props.only_translated || searchParams.get('only_translated'); - const sort = searchParams.get('sort') || 'score'; - const order = searchParams.get('order') || 'desc'; + const { page, ...params } = paramsBuilder(props); const query = useInfiniteQuery<AnimeCatalogResponse, Error>({ - queryKey: [ - 'list', - { - page, - search, - types, - statuses, - seasons, - ageRatings, - years, - lang, - genres, - studios, - sort, - order, - }, - ], - initialPageParam: iPage || page, + queryKey: key({ page, ...params }), + initialPageParam: props.iPage || page, getNextPageParam: (lastPage: AnimeCatalogResponse) => { const nextPage = lastPage.pagination.page + 1; return nextPage > lastPage.pagination.pages ? undefined : nextPage; }, queryFn: ({ pageParam = page }) => getAnimeCatalog({ - params: { - query: search, - years: years.length == 2 ? years : undefined, - rating: ageRatings, - season: seasons, - status: statuses, - media_type: types, - sort: [ - `${sort}:${order}`, - ...(sort === 'score' ? ['scored_by:desc'] : []), - ], - genres, - studios, - only_translated: Boolean(lang), - }, + params, page: Number(pageParam), size: 20, }), @@ -76,8 +56,8 @@ const useAnimeCatalog = ({ page, iPage, ...props }: Props) => { ...data, pages: data.pages.map((a) => ({ ...a, - list: convertAnimeList<API.Anime>({ - anime: a.list, + list: convertTitleList<API.Anime>({ + data: a.list, titleLanguage: titleLanguage!, }), })), @@ -98,4 +78,21 @@ const useAnimeCatalog = ({ page, iPage, ...props }: Props) => { }; }; +export const prefetchAnimeCatalog = (props: Props) => { + const queryClient = getQueryClient(); + + const { page, ...params } = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + queryKey: key({ page, ...params }), + initialPageParam: props.iPage || page, + queryFn: ({ pageParam = page }) => + getAnimeCatalog({ + params, + page: Number(pageParam), + size: 20, + }), + }); +}; + export default useAnimeCatalog; diff --git a/services/hooks/anime/use-anime-info.ts b/services/hooks/anime/use-anime-info.ts index 226085f7..c3724e4b 100644 --- a/services/hooks/anime/use-anime-info.ts +++ b/services/hooks/anime/use-anime-info.ts @@ -1,25 +1,44 @@ -import { useQuery } from '@tanstack/react-query'; +import { QueryKey, useQuery } from '@tanstack/react-query'; import getAnimeInfo, { Params } from '@/services/api/anime/getAnimeInfo'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; -const useAnimeInfo = ({ slug }: Params, options?: Hikka.QueryOptions) => { +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', +}); + +export const key = (params: Params): QueryKey => ['anime', params.slug]; + +const useAnimeInfo = (props: Params, options?: Hikka.QueryOptions) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); return useQuery({ - queryKey: ['anime', slug], + queryKey: key(params), queryFn: () => getAnimeInfo({ - params: { - slug, - }, + params, }), ...options, select: (data) => - convertAnime<API.AnimeInfo>({ + convertTitle({ titleLanguage: titleLanguage!, - anime: data, + data: data, + }), + }); +}; + +export const prefetchAnimeInfo = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => + getAnimeInfo({ + params, }), }); }; diff --git a/services/hooks/anime/use-characters.ts b/services/hooks/anime/use-characters.ts index a0843021..7c1ddf81 100644 --- a/services/hooks/anime/use-characters.ts +++ b/services/hooks/anime/use-characters.ts @@ -1,16 +1,40 @@ +import { QueryKey } from '@tanstack/react-query'; + import getAnimeCharacters, { Params, } from '@/services/api/anime/getAnimeCharacters'; import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', +}); + +export const key = (params: Params): QueryKey => ['characters', params.slug]; + +const useCharacters = (props: Params) => { + const params = paramsBuilder(props); -const useCharacters = ({ slug }: Params) => { return useInfiniteList({ - queryKey: ['characters', slug], + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getAnimeCharacters({ + params, + page: pageParam, + }), + }); +}; + +export const prefetchCharacters = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getAnimeCharacters({ - params: { - slug: String(slug), - }, + params, page: pageParam, }), }); diff --git a/services/hooks/anime/use-franchise.ts b/services/hooks/anime/use-franchise.ts index b8a00061..e33eca85 100644 --- a/services/hooks/anime/use-franchise.ts +++ b/services/hooks/anime/use-franchise.ts @@ -1,26 +1,36 @@ +import { QueryKey } from '@tanstack/react-query'; + import getAnimeFranchise, { Params, } from '@/services/api/anime/getAnimeFranchise'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnimeList } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitleList } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', +}); + +export const key = (params: Params): QueryKey => ['franchise', params.slug]; -const useFranchise = ({ slug }: Params) => { +const useFranchise = (props: Params) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); return useInfiniteList({ - queryKey: ['franchise', slug], + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getAnimeFranchise({ - params: { slug }, + params, page: pageParam, }), select: (data) => ({ ...data, pages: data.pages.map((a) => ({ ...a, - list: convertAnimeList<API.Anime>({ - anime: a.list, + list: convertTitleList<API.Anime>({ + data: a.list, titleLanguage: titleLanguage!, }), })), @@ -28,4 +38,19 @@ const useFranchise = ({ slug }: Params) => { }); }; +export const prefetchFranchise = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getAnimeFranchise({ + params, + page: pageParam, + }), + }); +}; + export default useFranchise; diff --git a/services/hooks/anime/use-staff.ts b/services/hooks/anime/use-staff.ts index acc76f18..d5c5be5b 100644 --- a/services/hooks/anime/use-staff.ts +++ b/services/hooks/anime/use-staff.ts @@ -1,11 +1,34 @@ +import { QueryKey } from '@tanstack/react-query'; + import getAnimeStaff, { Params } from '@/services/api/anime/getAnimeStaff'; import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', +}); + +export const key = (params: Params): QueryKey => ['staff', params.slug]; + +const useStaff = (props: Params) => { + const params = paramsBuilder(props); -const useStaff = ({ slug }: Params) => { return useInfiniteList({ - queryKey: ['staff', slug], + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getAnimeStaff({ params, page: pageParam }), + }); +}; + +export const prefetchStaff = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), queryFn: ({ pageParam = 1 }) => - getAnimeStaff({ params: { slug }, page: pageParam }), + getAnimeStaff({ params, page: pageParam }), }); }; diff --git a/services/hooks/auth/use-session.ts b/services/hooks/auth/use-session.ts index c0ce64f3..1efa15f9 100644 --- a/services/hooks/auth/use-session.ts +++ b/services/hooks/auth/use-session.ts @@ -2,10 +2,13 @@ import { useQuery } from '@tanstack/react-query'; import getLoggedUserInfo from '@/services/api/user/getLoggedUserInfo'; import { deleteCookie } from '@/utils/cookies'; +import getQueryClient from '@/utils/get-query-client'; + +export const key = () => ['logged-user']; const useSession = () => { const { data: user } = useQuery({ - queryKey: ['loggedUser'], + queryKey: key(), queryFn: () => getLoggedUserInfo(), }); @@ -17,4 +20,13 @@ const useSession = () => { return { logout, user }; }; +export const prefetchSession = async () => { + const queryClient = getQueryClient(); + + await queryClient.prefetchQuery({ + queryKey: key(), + queryFn: () => getLoggedUserInfo(), + }); +}; + export default useSession; diff --git a/services/hooks/characters/use-character-anime.ts b/services/hooks/characters/use-character-anime.ts index afd8912e..e94984d2 100644 --- a/services/hooks/characters/use-character-anime.ts +++ b/services/hooks/characters/use-character-anime.ts @@ -1,20 +1,31 @@ +import { QueryKey } from '@tanstack/react-query'; + import getCharacterAnime, { Params, } from '@/services/api/characters/getCharacterAnime'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', +}); + +export const key = (params: Params): QueryKey => [ + 'character-anime', + params.slug, +]; -const useCharacterAnime = ({ slug }: Params) => { +const useCharacterAnime = (props: Params) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); return useInfiniteList({ - queryKey: ['characterAnime', slug], + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getCharacterAnime({ - params: { - slug, - }, + params, page: pageParam, }), select: (data) => ({ @@ -23,8 +34,8 @@ const useCharacterAnime = ({ slug }: Params) => { ...a, list: a.list.map((ch) => ({ ...ch, - anime: convertAnime<API.Anime>({ - anime: ch.anime, + anime: convertTitle<API.Anime>({ + data: ch.anime, titleLanguage: titleLanguage!, }), })), @@ -33,4 +44,19 @@ const useCharacterAnime = ({ slug }: Params) => { }); }; +export const prefetchCharacterAnime = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getCharacterAnime({ + params, + page: pageParam, + }), + }); +}; + export default useCharacterAnime; diff --git a/services/hooks/characters/use-character-info.ts b/services/hooks/characters/use-character-info.ts index 48b78330..cce122ac 100644 --- a/services/hooks/characters/use-character-info.ts +++ b/services/hooks/characters/use-character-info.ts @@ -1,13 +1,32 @@ -import { useQuery } from '@tanstack/react-query'; +import { QueryKey, useQuery } from '@tanstack/react-query'; import getCharacterInfo, { Params, } from '@/services/api/characters/getCharacterInfo'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', +}); + +export const key = (params: Params): QueryKey => ['character', params.slug]; + +const useCharacterInfo = (props: Params) => { + const params = paramsBuilder(props); -const useCharacterInfo = ({ slug }: Params) => { return useQuery({ - queryKey: ['character', slug], - queryFn: () => getCharacterInfo({ params: { slug } }), + queryKey: key(params), + queryFn: () => getCharacterInfo({ params }), + }); +}; + +export const prefetchCharacterInfo = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getCharacterInfo({ params }), }); }; diff --git a/services/hooks/characters/use-character-manga.ts b/services/hooks/characters/use-character-manga.ts new file mode 100644 index 00000000..f6844046 --- /dev/null +++ b/services/hooks/characters/use-character-manga.ts @@ -0,0 +1,62 @@ +import { QueryKey } from '@tanstack/react-query'; + +import getCharacterManga, { + Params, +} from '@/services/api/characters/getCharacterManga'; +import useInfiniteList from '@/services/hooks/use-infinite-list'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', +}); + +export const key = (params: Params): QueryKey => [ + 'character-manga', + params.slug, +]; + +const useCharacterManga = (props: Params) => { + const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); + + return useInfiniteList({ + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getCharacterManga({ + params, + page: pageParam, + }), + select: (data) => ({ + ...data, + pages: data.pages.map((a) => ({ + ...a, + list: a.list.map((ch) => ({ + ...ch, + manga: convertTitle<API.Manga>({ + data: ch.manga, + titleLanguage: titleLanguage!, + }), + })), + })), + }), + }); +}; + +export const prefetchCharacterManga = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getCharacterManga({ + params, + page: pageParam, + }), + }); +}; + +export default useCharacterManga; diff --git a/services/hooks/characters/use-character-novel.ts b/services/hooks/characters/use-character-novel.ts new file mode 100644 index 00000000..918146ea --- /dev/null +++ b/services/hooks/characters/use-character-novel.ts @@ -0,0 +1,62 @@ +import { QueryKey } from '@tanstack/react-query'; + +import getCharacterNovel, { + Params, +} from '@/services/api/characters/getCharacterNovel'; +import useInfiniteList from '@/services/hooks/use-infinite-list'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', +}); + +export const key = (params: Params): QueryKey => [ + 'character-novel', + params.slug, +]; + +const useCharacterNovel = (props: Params) => { + const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); + + return useInfiniteList({ + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getCharacterNovel({ + params, + page: pageParam, + }), + select: (data) => ({ + ...data, + pages: data.pages.map((a) => ({ + ...a, + list: a.list.map((ch) => ({ + ...ch, + novel: convertTitle<API.Novel>({ + data: ch.novel, + titleLanguage: titleLanguage!, + }), + })), + })), + }), + }); +}; + +export const prefetchCharacterNovel = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getCharacterNovel({ + params, + page: pageParam, + }), + }); +}; + +export default useCharacterNovel; diff --git a/services/hooks/characters/use-character-voices.ts b/services/hooks/characters/use-character-voices.ts index 282dc6ba..b8e29906 100644 --- a/services/hooks/characters/use-character-voices.ts +++ b/services/hooks/characters/use-character-voices.ts @@ -1,20 +1,31 @@ +import { QueryKey } from '@tanstack/react-query'; + import getCharacterVoices, { Params, } from '@/services/api/characters/getCharacterVoices'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', +}); + +export const key = (params: Params): QueryKey => [ + 'character-voices', + params.slug, +]; -const usePersonCharacters = ({ slug }: Params) => { +const useCharacterVoices = (props: Params) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); return useInfiniteList({ - queryKey: ['characterVoices', slug], + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getCharacterVoices({ - params: { - slug, - }, + params, page: pageParam, }), select: (data) => ({ @@ -23,8 +34,8 @@ const usePersonCharacters = ({ slug }: Params) => { ...a, list: a.list.map((ch) => ({ ...ch, - anime: convertAnime<API.AnimeInfo>({ - anime: ch.anime, + anime: convertTitle<API.AnimeInfo>({ + data: ch.anime, titleLanguage: titleLanguage!, }), })), @@ -33,4 +44,19 @@ const usePersonCharacters = ({ slug }: Params) => { }); }; -export default usePersonCharacters; +export const prefetchCharacterVoices = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getCharacterVoices({ + params, + page: pageParam, + }), + }); +}; + +export default useCharacterVoices; diff --git a/services/hooks/collections/use-collection.ts b/services/hooks/collections/use-collection.ts index c717a3be..bb12303f 100644 --- a/services/hooks/collections/use-collection.ts +++ b/services/hooks/collections/use-collection.ts @@ -1,15 +1,52 @@ -import { useQuery } from '@tanstack/react-query'; +import { QueryKey, useQuery } from '@tanstack/react-query'; import getCollection, { Params, } from '@/services/api/collections/getCollection'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + reference: props.reference || '', +}); + +export const key = (params: Params): QueryKey => [ + 'collection', + params.reference, +]; + +const useCollection = (props: Params, options?: Hikka.QueryOptions) => { + const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); -const useCollection = ({ reference }: Params, options?: Hikka.QueryOptions) => { return useQuery({ - queryKey: ['collection', reference], - queryFn: () => getCollection({ params: { reference } }), + queryKey: key(params), + queryFn: () => getCollection({ params }), + select: (data) => ({ + ...data, + collection: data.collection.map((c) => ({ + ...c, + content: { + ...convertTitle({ + data: c.content, + titleLanguage: titleLanguage!, + }), + }, + })), + }), ...options, }); }; +export const prefetchCollection = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getCollection({ params }), + }); +}; + export default useCollection; diff --git a/services/hooks/collections/use-collections.ts b/services/hooks/collections/use-collections.ts index dc31df16..2eec2b82 100644 --- a/services/hooks/collections/use-collections.ts +++ b/services/hooks/collections/use-collections.ts @@ -1,18 +1,59 @@ -import { useQuery } from '@tanstack/react-query'; +import { QueryKey, useQuery } from '@tanstack/react-query'; import getCollections, { Params, } from '@/services/api/collections/getCollections'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + page: props.page || 1, + sort: props.sort || 'system_ranking', +}); + +export const key = (params: Params): QueryKey => ['collections', params]; + +const useCollections = (props: Params, options?: Hikka.QueryOptions) => { + const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); -const useCollections = ( - { page, sort = 'system_ranking' }: Params, - options?: Hikka.QueryOptions, -) => { return useQuery({ - queryKey: ['collections', { page, sort }], - queryFn: () => getCollections({ page, params: { sort } }), + queryKey: key(params), + queryFn: () => + getCollections({ + page: params.page, + params: { sort: params.sort }, + }), + select: (data) => ({ + pagination: data.pagination, + list: data.list.map((l) => ({ + ...l, + collection: l.collection.map((c) => ({ + ...c, + content: convertTitle({ + data: c.content, + titleLanguage: titleLanguage!, + }), + })), + })), + }), ...options, }); }; +export const prefetchCollections = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => + getCollections({ + page: params.page, + params: { sort: params.sort }, + }), + }); +}; + export default useCollections; diff --git a/services/hooks/comments/use-comment-thread.ts b/services/hooks/comments/use-comment-thread.ts index 67a1b3a4..5745c20c 100644 --- a/services/hooks/comments/use-comment-thread.ts +++ b/services/hooks/comments/use-comment-thread.ts @@ -1,23 +1,43 @@ -import { useQuery } from '@tanstack/react-query'; +import { QueryKey, useQuery } from '@tanstack/react-query'; import getCommentThread, { Params, } from '@/services/api/comments/getCommentThread'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + reference: props.reference || '', +}); + +export const key = (params: Params): QueryKey => [ + 'comment-thread', + params.reference, +]; + +const useCommentThread = (props: Params, options?: Hikka.QueryOptions) => { + const params = paramsBuilder(props); -const useCommentThread = ( - { reference }: Params, - options?: Hikka.QueryOptions, -) => { return useQuery({ - queryKey: ['commentThread', reference], + queryKey: key(params), queryFn: () => getCommentThread({ - params: { - reference, - }, + params, }), ...options, }); }; +export const prefetchCommentThread = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => + getCommentThread({ + params, + }), + }); +}; + export default useCommentThread; diff --git a/services/hooks/comments/use-comments.ts b/services/hooks/comments/use-comments.ts index 3b29d0e8..5a75c294 100644 --- a/services/hooks/comments/use-comments.ts +++ b/services/hooks/comments/use-comments.ts @@ -1,22 +1,47 @@ +import { QueryKey } from '@tanstack/react-query'; + import getComments, { Params } from '@/services/api/comments/getComments'; import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug || '', + content_type: props.content_type || 'anime', +}); + +export const key = (params: Params): QueryKey => [ + 'comments', + params.slug, + params.content_type, +]; + +const useComments = (props: Params, options?: Hikka.QueryOptions) => { + const params = paramsBuilder(props); -const useComments = ( - { slug, content_type }: Params, - options?: Hikka.QueryOptions, -) => { return useInfiniteList({ - queryKey: ['comments', slug, content_type], + queryKey: key(params), queryFn: ({ pageParam }) => getComments({ - params: { - slug, - content_type, - }, + params, page: pageParam, }), ...options, }); }; +export const prefetchComments = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getComments({ + params, + page: pageParam, + }), + }); +}; + export default useComments; diff --git a/services/hooks/comments/use-global-comments.ts b/services/hooks/comments/use-global-comments.ts index 578958df..02b0866b 100644 --- a/services/hooks/comments/use-global-comments.ts +++ b/services/hooks/comments/use-global-comments.ts @@ -1,9 +1,14 @@ +import { QueryKey } from '@tanstack/react-query'; + import getGlobalComments from '@/services/api/comments/getGlobalComments'; import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const key = (): QueryKey => ['global-comments']; const useGlobalComments = () => { return useInfiniteList({ - queryKey: ['globalComments'], + queryKey: key(), queryFn: ({ pageParam }) => getGlobalComments({ page: pageParam, @@ -11,4 +16,17 @@ const useGlobalComments = () => { }); }; +export const prefetchGlobalComments = () => { + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(), + queryFn: ({ pageParam = 1 }) => + getGlobalComments({ + page: pageParam, + }), + }); +}; + export default useGlobalComments; diff --git a/services/hooks/comments/use-latest-comments.ts b/services/hooks/comments/use-latest-comments.ts index fa573c06..ecc42147 100644 --- a/services/hooks/comments/use-latest-comments.ts +++ b/services/hooks/comments/use-latest-comments.ts @@ -1,10 +1,22 @@ -import { useQuery } from '@tanstack/react-query'; +import { QueryKey, useQuery } from '@tanstack/react-query'; import getLatestComments from '@/services/api/comments/getLatestComments'; +import getQueryClient from '@/utils/get-query-client'; + +export const key = (): QueryKey => ['latest-comments']; const useLatestComments = () => { return useQuery({ - queryKey: ['latestComments'], + queryKey: key(), + queryFn: () => getLatestComments(), + }); +}; + +export const prefetchLatestComments = () => { + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(), queryFn: () => getLatestComments(), }); }; diff --git a/services/hooks/companies/use-companies.ts b/services/hooks/companies/use-companies.ts index 0629ba94..c1281421 100644 --- a/services/hooks/companies/use-companies.ts +++ b/services/hooks/companies/use-companies.ts @@ -1,10 +1,33 @@ import { useQuery } from '@tanstack/react-query'; import getCompanies, { Params } from '@/services/api/companies/getCompanies'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + query: props.query || '', + type: props.type || 'studio', +}); + +export const key = (params: Params) => ['companies', params]; + +const useCompanies = (props: Params) => { + const params = paramsBuilder(props); -const useCompanies = (params: Params) => { return useQuery({ - queryKey: ['companies', { ...params }], + queryKey: key(params), + queryFn: () => + getCompanies({ + params, + }), + }); +}; + +export const prefetchCompanies = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), queryFn: () => getCompanies({ params, diff --git a/services/hooks/edit/todo/use-todo-anime.ts b/services/hooks/edit/todo/use-todo-anime.ts index f412cc9d..20ad5293 100644 --- a/services/hooks/edit/todo/use-todo-anime.ts +++ b/services/hooks/edit/todo/use-todo-anime.ts @@ -1,16 +1,24 @@ import getTodoAnime, { Params } from '@/services/api/edit/todo/getTodoAnime'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnimeList } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitleList } from '@/utils/title-adapter'; -const useTodoAnime = ({ param }: Params) => { +export const paramsBuilder = (props: Params): Params => ({ + param: props.param, +}); + +export const key = (params: Params) => ['todo-edit-list', params.param]; + +const useTodoAnime = (props: Params) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); return useInfiniteList({ - queryKey: ['list', param], + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getTodoAnime({ - params: { param }, + params, page: pageParam, size: 18, }), @@ -18,8 +26,8 @@ const useTodoAnime = ({ param }: Params) => { ...data, pages: data.pages.map((a) => ({ ...a, - list: convertAnimeList<API.Anime>({ - anime: a.list, + list: convertTitleList<API.Anime>({ + data: a.list, titleLanguage: titleLanguage!, }), })), @@ -27,4 +35,20 @@ const useTodoAnime = ({ param }: Params) => { }); }; +export const prefetchTodoAnime = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getTodoAnime({ + params, + page: pageParam, + size: 18, + }), + }); +}; + export default useTodoAnime; diff --git a/services/hooks/edit/use-action-edit.ts b/services/hooks/edit/use-action-edit.ts index 4dc5618c..8f45ad71 100644 --- a/services/hooks/edit/use-action-edit.ts +++ b/services/hooks/edit/use-action-edit.ts @@ -33,7 +33,7 @@ const useActionEdit = ({ action }: Props) => { }); await queryClient.invalidateQueries({ - queryKey: ['editList'], + queryKey: ['edit-list'], exact: false, }); }, diff --git a/services/hooks/edit/use-edit-list.ts b/services/hooks/edit/use-edit-list.ts index 4f758d8d..a7bec0f1 100644 --- a/services/hooks/edit/use-edit-list.ts +++ b/services/hooks/edit/use-edit-list.ts @@ -1,52 +1,59 @@ import { useQuery } from '@tanstack/react-query'; -import { useSearchParams } from 'next/navigation'; import getEditList, { Params } from '@/services/api/edit/getEditList'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; -const useEditList = ({ page }: Params, options?: Hikka.QueryOptions) => { +export const paramsBuilder = (props: Params): Params => ({ + page: props.page || 1, + content_type: props.content_type || undefined, + sort: props.sort || undefined, + status: props.status || undefined, + author: props.author || undefined, + moderator: props.moderator || undefined, +}); + +export const key = (params: Params) => ['edit-list', params]; + +const useEditList = (props: Params, options?: Hikka.QueryOptions) => { const { titleLanguage } = useSettingsContext(); - const searchParams = useSearchParams()!; - const content_type = searchParams.get('content_type'); - const order = searchParams.get('order') || 'desc'; - const sort = searchParams.get('sort') || 'edit_id'; - const edit_status = searchParams.get('edit_status'); - const author = searchParams.get('author'); - const moderator = searchParams.get('moderator'); + const { page, ...params } = paramsBuilder(props); return useQuery({ - queryKey: [ - 'editList', - { page, content_type, order, sort, edit_status, author, moderator }, - ], + queryKey: key({ ...params, page }), queryFn: () => getEditList({ page: Number(page), - params: { - sort: [`${sort}:${order}`], - status: edit_status as API.EditStatus, - content_type: content_type as API.ContentType, - author, - moderator, - }, + params: params, }), select: (data) => ({ ...data, list: data.list.map((e) => ({ ...e, - content: - 'title_ua' in e.content - ? convertAnime({ - anime: e.content, - titleLanguage: titleLanguage!, - }) - : e.content, + content: convertTitle({ + data: e.content, + titleLanguage: titleLanguage!, + }), })), }), ...options, }); }; +export const prefetchEditList = (props: Params) => { + const { page, ...params } = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key({ ...params, page }), + queryFn: () => + getEditList({ + page: Number(page), + params: params, + }), + }); +}; + export default useEditList; diff --git a/services/hooks/edit/use-edit.ts b/services/hooks/edit/use-edit.ts index eb00e6a3..26d4c3da 100644 --- a/services/hooks/edit/use-edit.ts +++ b/services/hooks/edit/use-edit.ts @@ -1,16 +1,35 @@ import { useQuery } from '@tanstack/react-query'; import getEdit, { Params } from '@/services/api/edit/getEdit'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + edit_id: props.edit_id, +}); + +export const key = (params: Params) => ['edit', params.edit_id]; const useEdit = <T extends API.Edit>( - { edit_id }: Params, + props: Params, options?: Hikka.QueryOptions, ) => { + const params = paramsBuilder(props); + return useQuery<T, Error>({ - queryKey: ['edit', String(edit_id)], - queryFn: () => getEdit({ params: { edit_id: edit_id } }), + queryKey: key(params), + queryFn: () => getEdit({ params }), ...options, }); }; +export const prefetchEdit = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getEdit({ params }), + }); +}; + export default useEdit; diff --git a/services/hooks/favorite/use-add-favorite.ts b/services/hooks/favorite/use-add-favorite.ts index b979ac95..b8468c4a 100644 --- a/services/hooks/favorite/use-add-favorite.ts +++ b/services/hooks/favorite/use-add-favorite.ts @@ -15,6 +15,10 @@ const useAddFavorite = ({ slug, content_type }: Params) => { }, }), onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['favorites'], + exact: false, + }); await queryClient.invalidateQueries({ queryKey: ['favorite'], exact: false, diff --git a/services/hooks/favorite/use-favorite.ts b/services/hooks/favorite/use-favorite.ts index 8c4f998d..2ba392a3 100644 --- a/services/hooks/favorite/use-favorite.ts +++ b/services/hooks/favorite/use-favorite.ts @@ -1,11 +1,35 @@ import { useQuery } from '@tanstack/react-query'; import getFavourite, { Params } from '@/services/api/favourite/getFavourite'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, + content_type: props.content_type, +}); + +export const key = (params: Params) => [ + 'favorite', + params.slug, + { content_type: params.content_type }, +]; + +const useFavorite = (props: Params) => { + const params = paramsBuilder(props); -const useFavorite = ({ slug, content_type }: Params) => { return useQuery({ - queryKey: ['favorite', slug, { content_type }], - queryFn: () => getFavourite({ params: { slug, content_type } }), + queryKey: key(params), + queryFn: () => getFavourite({ params }), + }); +}; + +export const prefetchFavorite = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getFavourite({ params }), }); }; diff --git a/services/hooks/favorite/use-favorites.ts b/services/hooks/favorite/use-favorites.ts index fa835673..d8677c08 100644 --- a/services/hooks/favorite/use-favorites.ts +++ b/services/hooks/favorite/use-favorites.ts @@ -1,26 +1,34 @@ import getFavouriteList, { + FavoriteContent, Params, } from '@/services/api/favourite/getFavouriteList'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; -const useFavorites = <TContent extends API.Content>({ - username, - content_type, -}: Params) => { +export const paramsBuilder = (props: Params): Params => ({ + username: props.username, + content_type: props.content_type, +}); + +export const key = (params: Params) => [ + 'favorites', + params.username, + { content_type: params.content_type }, +]; + +const useFavorites = <TContent extends API.Content>(props: Params) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); return useInfiniteList({ - queryKey: ['favorites', username, { content_type }], + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getFavouriteList<TContent>({ page: pageParam, size: 18, - params: { - username, - content_type, - }, + params, }), staleTime: 0, select: (data) => ({ @@ -29,16 +37,30 @@ const useFavorites = <TContent extends API.Content>({ ...a, list: a.list.map((s) => ({ ...s, - ...(s.data_type === 'anime' - ? convertAnime<API.AnimeInfo>({ - titleLanguage: titleLanguage!, - anime: s as API.AnimeInfo, - }) - : {}), + ...convertTitle<FavoriteContent>({ + titleLanguage: titleLanguage!, + data: s, + }), })), })), }), }); }; +export const prefetchFavorites = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getFavouriteList({ + page: pageParam, + size: 18, + params, + }), + }); +}; + export default useFavorites; diff --git a/services/hooks/follow/use-follow-checker.ts b/services/hooks/follow/use-follow-checker.ts index bccf252b..1b76bee4 100644 --- a/services/hooks/follow/use-follow-checker.ts +++ b/services/hooks/follow/use-follow-checker.ts @@ -1,19 +1,38 @@ import { useQuery } from '@tanstack/react-query'; import checkFollow, { Params } from '@/services/api/follow/checkFollow'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + username: props.username, +}); + +export const key = (params: Params) => ['follow-checker', params.username]; + +const useFollowChecker = (props: Params, options?: Hikka.QueryOptions) => { + const params = paramsBuilder(props); -const useFollowChecker = ( - { username }: Params, - options?: Hikka.QueryOptions, -) => { return useQuery({ - queryKey: ['followChecker', username], + queryKey: key(params), queryFn: () => checkFollow({ - params: { username }, + params, }), ...options, }); }; +export const prefetchFollowChecker = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => + checkFollow({ + params, + }), + }); +}; + export default useFollowChecker; diff --git a/services/hooks/follow/use-follow-stats.ts b/services/hooks/follow/use-follow-stats.ts index 2d0aa107..8163893c 100644 --- a/services/hooks/follow/use-follow-stats.ts +++ b/services/hooks/follow/use-follow-stats.ts @@ -1,11 +1,30 @@ import { useQuery } from '@tanstack/react-query'; import getFollowStats, { Params } from '@/services/api/follow/getFollowStats'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + username: props.username, +}); + +export const key = (params: Params) => ['follow-stats', params.username]; + +const useFollowStats = (props: Params) => { + const params = paramsBuilder(props); -const useFollowStats = ({ username }: Params) => { return useQuery({ - queryKey: ['followStats', username], - queryFn: () => getFollowStats({ params: { username } }), + queryKey: key(params), + queryFn: () => getFollowStats({ params }), + }); +}; + +export const prefetchFollowStats = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getFollowStats({ params }), }); }; diff --git a/services/hooks/anime/use-anime-genres.ts b/services/hooks/genres/use-genres.ts similarity index 50% rename from services/hooks/anime/use-anime-genres.ts rename to services/hooks/genres/use-genres.ts index 5475d2b5..69c7ee0e 100644 --- a/services/hooks/anime/use-anime-genres.ts +++ b/services/hooks/genres/use-genres.ts @@ -2,13 +2,16 @@ import { useQuery } from '@tanstack/react-query'; import { groupOptions } from '@/components/ui/select'; -import getAnimeGenres from '@/services/api/anime/getAnimeGenres'; +import getGenres from '@/services/api/genres/getGenres'; import { GENRE_TYPES } from '@/utils/constants'; +import getQueryClient from '@/utils/get-query-client'; -const useAnimeGenres = () => { +export const key = () => ['genres']; + +const useGenres = () => { return useQuery({ - queryKey: ['animeGenres'], - queryFn: () => getAnimeGenres(), + queryKey: key(), + queryFn: () => getGenres(), select: (data) => groupOptions( data.list.map((genre) => ({ @@ -20,4 +23,13 @@ const useAnimeGenres = () => { }); }; -export default useAnimeGenres; +export const prefetchGenres = () => { + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(), + queryFn: () => getGenres(), + }); +}; + +export default useGenres; diff --git a/services/hooks/history/use-following-history.ts b/services/hooks/history/use-following-history.ts index ffb1a0d4..d6447084 100644 --- a/services/hooks/history/use-following-history.ts +++ b/services/hooks/history/use-following-history.ts @@ -1,13 +1,16 @@ import getFollowingHistory from '@/services/api/history/getFollowingHistory'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; -const useUserHistory = () => { +export const key = () => ['following-history']; + +const useFollowingHistory = () => { const { titleLanguage } = useSettingsContext(); return useInfiniteList({ - queryKey: ['followingHistory'], + queryKey: key(), queryFn: ({ pageParam }) => getFollowingHistory({ page: pageParam, @@ -20,8 +23,8 @@ const useUserHistory = () => { ...h, content: h.content && 'title_ua' in h.content - ? convertAnime<API.Anime>({ - anime: h.content, + ? convertTitle({ + data: h.content, titleLanguage: titleLanguage!, }) : h.content, @@ -31,4 +34,17 @@ const useUserHistory = () => { }); }; -export default useUserHistory; +export const prefetchFollowingHistory = () => { + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(), + queryFn: ({ pageParam = 1 }) => + getFollowingHistory({ + page: pageParam, + }), + }); +}; + +export default useFollowingHistory; diff --git a/services/hooks/history/use-user-history.ts b/services/hooks/history/use-user-history.ts index 46c7d0a2..58b1491b 100644 --- a/services/hooks/history/use-user-history.ts +++ b/services/hooks/history/use-user-history.ts @@ -1,17 +1,25 @@ import getUserHistory, { Params } from '@/services/api/history/getUserHistory'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; -const useUserHistory = ({ username }: Params) => { +export const paramsBuilder = (props: Params): Params => ({ + username: props.username, +}); + +export const key = (params: Params) => ['history', params.username]; + +const useUserHistory = (props: Params) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); return useInfiniteList({ - queryKey: ['history', username], + queryKey: key(params), queryFn: ({ pageParam }) => getUserHistory({ page: pageParam, - params: { username }, + params, }), select: (data) => ({ ...data, @@ -21,8 +29,8 @@ const useUserHistory = ({ username }: Params) => { ...h, content: h.content && 'title_ua' in h.content - ? convertAnime<API.Anime>({ - anime: h.content, + ? convertTitle({ + data: h.content, titleLanguage: titleLanguage!, }) : h.content, @@ -32,4 +40,19 @@ const useUserHistory = ({ username }: Params) => { }); }; +export const prefetchUserHistory = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getUserHistory({ + page: pageParam, + params, + }), + }); +}; + export default useUserHistory; diff --git a/services/hooks/manga/use-manga-catalog.ts b/services/hooks/manga/use-manga-catalog.ts new file mode 100644 index 00000000..37e88a79 --- /dev/null +++ b/services/hooks/manga/use-manga-catalog.ts @@ -0,0 +1,95 @@ +import { QueryKey, useInfiniteQuery } from '@tanstack/react-query'; + +import getMangaCatalog, { + Params as MangaCatalogParams, + Response as MangaCatalogResponse, +} from '@/services/api/manga/getMangaCatalog'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitleList } from '@/utils/title-adapter'; + +export interface Props extends MangaCatalogParams { + page: number; + iPage: number; +} + +export const paramsBuilder = ( + props: Omit<Props, 'iPage'>, +): Omit<Props, 'iPage'> => ({ + page: props.page || 1, + query: props.query || undefined, + media_type: props.media_type || [], + status: props.status || [], + years: props.years || [], + only_translated: props.only_translated || undefined, + genres: props.genres || [], + sort: props.sort || ['score:desc'], +}); + +export const key = (params: Omit<Props, 'iPage'>): QueryKey => [ + 'manga-list', + params, +]; + +const useMangaCatalog = (props: Props) => { + const { titleLanguage } = useSettingsContext(); + + const { page, ...params } = paramsBuilder(props); + + const query = useInfiniteQuery<MangaCatalogResponse, Error>({ + queryKey: key({ page, ...params }), + initialPageParam: props.iPage || page, + getNextPageParam: (lastPage: MangaCatalogResponse) => { + const nextPage = lastPage.pagination.page + 1; + return nextPage > lastPage.pagination.pages ? undefined : nextPage; + }, + queryFn: ({ pageParam = page }) => + getMangaCatalog({ + params, + page: Number(pageParam), + size: 20, + }), + select: (data) => ({ + ...data, + pages: data.pages.map((a) => ({ + ...a, + list: convertTitleList<API.Manga>({ + data: a.list, + titleLanguage: titleLanguage!, + }), + })), + }), + }); + + const list = + query.data && query.data.pages.map((data) => data.list).flat(1); + const pagination = + query.data && + query.data.pages.length > 0 && + query.data.pages[query.data.pages.length - 1].pagination; + + return { + ...query, + list, + pagination, + }; +}; + +export const prefetchMangaCatalog = (props: Props) => { + const queryClient = getQueryClient(); + + const { page, ...params } = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + queryKey: key({ page, ...params }), + initialPageParam: props.iPage || page, + queryFn: ({ pageParam = page }) => + getMangaCatalog({ + params, + page: Number(pageParam), + size: 20, + }), + }); +}; + +export default useMangaCatalog; diff --git a/services/hooks/manga/use-manga-characters.ts b/services/hooks/manga/use-manga-characters.ts new file mode 100644 index 00000000..af9d8829 --- /dev/null +++ b/services/hooks/manga/use-manga-characters.ts @@ -0,0 +1,40 @@ +import { Params } from '@/services/api/anime/getAnimeCharacters'; +import getMangaCharacters from '@/services/api/manga/getMangaCharacters'; +import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['manga-characters', params.slug]; + +const useMangaCharacters = (props: Params) => { + const params = paramsBuilder(props); + + return useInfiniteList({ + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getMangaCharacters({ + params, + page: pageParam, + }), + }); +}; + +export const prefetchMangaCharacters = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getMangaCharacters({ + params, + page: pageParam, + }), + }); +}; + +export default useMangaCharacters; diff --git a/services/hooks/manga/use-manga-info.ts b/services/hooks/manga/use-manga-info.ts new file mode 100644 index 00000000..f6009ca6 --- /dev/null +++ b/services/hooks/manga/use-manga-info.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query'; + +import { Params } from '@/services/api/anime/getAnimeInfo'; +import getMangaInfo from '@/services/api/manga/getMangaInfo'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['manga', params.slug]; + +const useMangaInfo = (props: Params, options?: Hikka.QueryOptions) => { + const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); + + return useQuery({ + queryKey: key(params), + queryFn: () => + getMangaInfo({ + params, + }), + ...options, + select: (data) => + convertTitle<API.MangaInfo>({ + titleLanguage: titleLanguage!, + data: data, + }), + }); +}; + +export const prefetchMangaInfo = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => + getMangaInfo({ + params, + }), + }); +}; + +export default useMangaInfo; diff --git a/services/hooks/notifications/use-notifications-count.ts b/services/hooks/notifications/use-notifications-count.ts index e52aeab2..cf08f11e 100644 --- a/services/hooks/notifications/use-notifications-count.ts +++ b/services/hooks/notifications/use-notifications-count.ts @@ -1,14 +1,26 @@ import { useQuery } from '@tanstack/react-query'; import getNotificationsCount from '@/services/api/notifications/getNotificationsCount'; +import getQueryClient from '@/utils/get-query-client'; + +export const key = () => ['notifications-count']; const useNotificationsCount = () => { return useQuery({ queryFn: () => getNotificationsCount(), - queryKey: ['notificationsCount'], + queryKey: key(), staleTime: 0, refetchInterval: 30000, }); }; +export const prefetchNotificationsCount = () => { + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryFn: () => getNotificationsCount(), + queryKey: key(), + }); +}; + export default useNotificationsCount; diff --git a/services/hooks/notifications/use-notifications.ts b/services/hooks/notifications/use-notifications.ts index 4ba32da0..0f5c1baf 100644 --- a/services/hooks/notifications/use-notifications.ts +++ b/services/hooks/notifications/use-notifications.ts @@ -1,12 +1,25 @@ import getNotifications from '@/services/api/notifications/getNotifications'; import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const key = () => ['notifications']; const useNotifications = () => { return useInfiniteList({ queryFn: ({ pageParam }) => getNotifications({ page: pageParam }), - queryKey: ['notifications'], + queryKey: key(), staleTime: 0, }); }; +export const prefetchNotifications = () => { + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(), + queryFn: ({ pageParam = 1 }) => getNotifications({ page: pageParam }), + }); +}; + export default useNotifications; diff --git a/services/hooks/notifications/use-seen-notification.ts b/services/hooks/notifications/use-seen-notification.ts index eca0b610..c55256ce 100644 --- a/services/hooks/notifications/use-seen-notification.ts +++ b/services/hooks/notifications/use-seen-notification.ts @@ -20,7 +20,7 @@ const useSeenNotification = () => { }); queryClient.refetchQueries({ - queryKey: ['notificationsCount'], + queryKey: ['notifications-count'], exact: false, }); }, diff --git a/services/hooks/novel/use-novel-catalog.ts b/services/hooks/novel/use-novel-catalog.ts new file mode 100644 index 00000000..d758c3af --- /dev/null +++ b/services/hooks/novel/use-novel-catalog.ts @@ -0,0 +1,95 @@ +import { QueryKey, useInfiniteQuery } from '@tanstack/react-query'; + +import getNovelCatalog, { + Params as NovelCatalogParams, + Response as NovelCatalogResponse, +} from '@/services/api/novel/getNovelCatalog'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitleList } from '@/utils/title-adapter'; + +export interface Props extends NovelCatalogParams { + page: number; + iPage: number; +} + +export const paramsBuilder = ( + props: Omit<Props, 'iPage'>, +): Omit<Props, 'iPage'> => ({ + page: props.page || 1, + query: props.query || undefined, + media_type: props.media_type || [], + status: props.status || [], + years: props.years || [], + only_translated: props.only_translated || undefined, + genres: props.genres || [], + sort: props.sort || ['score:desc'], +}); + +export const key = (params: Omit<Props, 'iPage'>): QueryKey => [ + 'novel-list', + params, +]; + +const useNovelCatalog = (props: Props) => { + const { titleLanguage } = useSettingsContext(); + + const { page, ...params } = paramsBuilder(props); + + const query = useInfiniteQuery<NovelCatalogResponse, Error>({ + queryKey: key({ page, ...params }), + initialPageParam: props.iPage || page, + getNextPageParam: (lastPage: NovelCatalogResponse) => { + const nextPage = lastPage.pagination.page + 1; + return nextPage > lastPage.pagination.pages ? undefined : nextPage; + }, + queryFn: ({ pageParam = page }) => + getNovelCatalog({ + params, + page: Number(pageParam), + size: 20, + }), + select: (data) => ({ + ...data, + pages: data.pages.map((a) => ({ + ...a, + list: convertTitleList<API.Novel>({ + data: a.list, + titleLanguage: titleLanguage!, + }), + })), + }), + }); + + const list = + query.data && query.data.pages.map((data) => data.list).flat(1); + const pagination = + query.data && + query.data.pages.length > 0 && + query.data.pages[query.data.pages.length - 1].pagination; + + return { + ...query, + list, + pagination, + }; +}; + +export const prefetchNovelCatalog = (props: Props) => { + const queryClient = getQueryClient(); + + const { page, ...params } = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + queryKey: key({ page, ...params }), + initialPageParam: props.iPage || page, + queryFn: ({ pageParam = page }) => + getNovelCatalog({ + params, + page: Number(pageParam), + size: 20, + }), + }); +}; + +export default useNovelCatalog; diff --git a/services/hooks/novel/use-novel-characters.ts b/services/hooks/novel/use-novel-characters.ts new file mode 100644 index 00000000..a5a7c6ce --- /dev/null +++ b/services/hooks/novel/use-novel-characters.ts @@ -0,0 +1,40 @@ +import { Params } from '@/services/api/anime/getAnimeCharacters'; +import getNovelCharacters from '@/services/api/novel/getNovelCharacters'; +import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['novel-characters', params.slug]; + +const useNovelCharacters = (props: Params) => { + const params = paramsBuilder(props); + + return useInfiniteList({ + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getNovelCharacters({ + params, + page: pageParam, + }), + }); +}; + +export const prefetchNovelCharacters = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getNovelCharacters({ + params, + page: pageParam, + }), + }); +}; + +export default useNovelCharacters; diff --git a/services/hooks/novel/use-novel-info.ts b/services/hooks/novel/use-novel-info.ts new file mode 100644 index 00000000..eb84bf4a --- /dev/null +++ b/services/hooks/novel/use-novel-info.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query'; + +import { Params } from '@/services/api/anime/getAnimeInfo'; +import getNovelInfo from '@/services/api/novel/getNovelInfo'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['novel', params.slug]; + +const useNovelInfo = (props: Params, options?: Hikka.QueryOptions) => { + const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); + + return useQuery({ + queryKey: key(params), + queryFn: () => + getNovelInfo({ + params, + }), + ...options, + select: (data) => + convertTitle<API.NovelInfo>({ + titleLanguage: titleLanguage!, + data: data, + }), + }); +}; + +export const prefetchNovelInfo = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => + getNovelInfo({ + params, + }), + }); +}; + +export default useNovelInfo; diff --git a/services/hooks/people/use-person-anime.ts b/services/hooks/people/use-person-anime.ts index 5394d6eb..c9c8587a 100644 --- a/services/hooks/people/use-person-anime.ts +++ b/services/hooks/people/use-person-anime.ts @@ -1,16 +1,24 @@ import getPersonAnime, { Params } from '@/services/api/people/getPersonAnime'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; -const usePersonAnime = ({ slug }: Params, options?: Hikka.QueryOptions) => { +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['person-anime', params.slug]; + +const usePersonAnime = (props: Params, options?: Hikka.QueryOptions) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); return useInfiniteList({ - queryKey: ['personAnime', slug], + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getPersonAnime({ - params: { slug }, + params, page: pageParam, }), select: (data) => ({ @@ -19,8 +27,8 @@ const usePersonAnime = ({ slug }: Params, options?: Hikka.QueryOptions) => { ...a, list: a.list.map((p) => ({ ...p, - anime: convertAnime<API.AnimeInfo>({ - anime: p.anime, + anime: convertTitle<API.AnimeInfo>({ + data: p.anime, titleLanguage: titleLanguage!, }), })), @@ -30,4 +38,19 @@ const usePersonAnime = ({ slug }: Params, options?: Hikka.QueryOptions) => { }); }; +export const prefetchPersonAnime = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getPersonAnime({ + params, + page: pageParam, + }), + }); +}; + export default usePersonAnime; diff --git a/services/hooks/people/use-person-characters.ts b/services/hooks/people/use-person-characters.ts index ae51e457..ab8c12ae 100644 --- a/services/hooks/people/use-person-characters.ts +++ b/services/hooks/people/use-person-characters.ts @@ -3,19 +3,24 @@ import getPersonCharacters, { } from '@/services/api/people/getPersonCharacters'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; -const usePersonCharacters = ( - { slug }: Params, - options?: Hikka.QueryOptions, -) => { +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['person-characters', params.slug]; + +const usePersonCharacters = (props: Params, options?: Hikka.QueryOptions) => { const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); return useInfiniteList({ - queryKey: ['personCharacters', slug], + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getPersonCharacters({ - params: { slug }, + params, page: pageParam, }), select: (data) => ({ @@ -24,8 +29,8 @@ const usePersonCharacters = ( ...a, list: a.list.map((ch) => ({ ...ch, - anime: convertAnime<API.AnimeInfo>({ - anime: ch.anime, + anime: convertTitle<API.AnimeInfo>({ + data: ch.anime, titleLanguage: titleLanguage!, }), })), @@ -35,4 +40,19 @@ const usePersonCharacters = ( }); }; +export const prefetchPersonCharacters = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getPersonCharacters({ + params, + page: pageParam, + }), + }); +}; + export default usePersonCharacters; diff --git a/services/hooks/people/use-person-info.ts b/services/hooks/people/use-person-info.ts index 209c1100..0e18cd61 100644 --- a/services/hooks/people/use-person-info.ts +++ b/services/hooks/people/use-person-info.ts @@ -1,11 +1,30 @@ import { useQuery } from '@tanstack/react-query'; import getPersonInfo, { Params } from '@/services/api/people/getPersonInfo'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['person', params.slug]; + +const usePersonInfo = (props: Params) => { + const params = paramsBuilder(props); -const usePersonInfo = ({ slug }: Params) => { return useQuery({ - queryKey: ['person', slug], - queryFn: () => getPersonInfo({ params: { slug } }), + queryKey: key(params), + queryFn: () => getPersonInfo({ params }), + }); +}; + +export const prefetchPersonInfo = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getPersonInfo({ params }), }); }; diff --git a/services/hooks/people/use-person-manga.ts b/services/hooks/people/use-person-manga.ts new file mode 100644 index 00000000..bcf915c7 --- /dev/null +++ b/services/hooks/people/use-person-manga.ts @@ -0,0 +1,56 @@ +import getPersonManga, { Params } from '@/services/api/people/getPersonManga'; +import useInfiniteList from '@/services/hooks/use-infinite-list'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['person-manga', params.slug]; + +const usePersonManga = (props: Params, options?: Hikka.QueryOptions) => { + const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); + + return useInfiniteList({ + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getPersonManga({ + params, + page: pageParam, + }), + select: (data) => ({ + ...data, + pages: data.pages.map((a) => ({ + ...a, + list: a.list.map((p) => ({ + ...p, + manga: convertTitle<API.MangaInfo>({ + data: p.manga, + titleLanguage: titleLanguage!, + }), + })), + })), + }), + ...options, + }); +}; + +export const prefetchPersonManga = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getPersonManga({ + params, + page: pageParam, + }), + }); +}; + +export default usePersonManga; diff --git a/services/hooks/people/use-person-novel.ts b/services/hooks/people/use-person-novel.ts new file mode 100644 index 00000000..a464e154 --- /dev/null +++ b/services/hooks/people/use-person-novel.ts @@ -0,0 +1,56 @@ +import getPersonNovel, { Params } from '@/services/api/people/getPersonNovel'; +import useInfiniteList from '@/services/hooks/use-infinite-list'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['person-novel', params.slug]; + +const usePersonNovel = (props: Params, options?: Hikka.QueryOptions) => { + const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); + + return useInfiniteList({ + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getPersonNovel({ + params, + page: pageParam, + }), + select: (data) => ({ + ...data, + pages: data.pages.map((a) => ({ + ...a, + list: a.list.map((p) => ({ + ...p, + novel: convertTitle<API.NovelInfo>({ + data: p.novel, + titleLanguage: titleLanguage!, + }), + })), + })), + }), + ...options, + }); +}; + +export const prefetchPersonNovel = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getPersonNovel({ + params, + page: pageParam, + }), + }); +}; + +export default usePersonNovel; diff --git a/services/hooks/watch/use-add-to-list.tsx b/services/hooks/read/use-add-read.ts similarity index 56% rename from services/hooks/watch/use-add-to-list.tsx rename to services/hooks/read/use-add-read.ts index 9b42c45d..0003aeac 100644 --- a/services/hooks/watch/use-add-to-list.tsx +++ b/services/hooks/read/use-add-read.ts @@ -1,36 +1,31 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import addWatch, { Params } from '@/services/api/watch/addWatch'; +import addRead from '@/services/api/read/addRead'; import { useModalContext } from '@/services/providers/modal-provider'; -const useAddToList = ({ slug }: { slug: string }) => { +const useAddRead = () => { const { closeModal } = useModalContext(); const queryClient = useQueryClient(); return useMutation({ - mutationKey: ['addToList', slug], - mutationFn: (mutationParams: Omit<Params, 'slug'>) => - addWatch({ - params: { - ...mutationParams, - slug: slug, - }, - }), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['list'] }); + mutationKey: ['add-to-read-list'], + mutationFn: addRead, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: ['novel-list'] }); + await queryClient.invalidateQueries({ queryKey: ['manga-list'] }); await queryClient.refetchQueries({ - queryKey: ['watch', slug], + queryKey: ['read'], exact: false, }); await queryClient.invalidateQueries({ - queryKey: ['watchList'], + queryKey: ['read-list'], exact: false, }); await queryClient.invalidateQueries({ queryKey: ['favorites'] }); await queryClient.invalidateQueries({ queryKey: ['franchise'] }); await queryClient.invalidateQueries({ queryKey: ['collection'] }); await queryClient.invalidateQueries({ - queryKey: ['animeSchedule', {}], + queryKey: ['anime-schedule'], exact: false, }); @@ -39,4 +34,4 @@ const useAddToList = ({ slug }: { slug: string }) => { }); }; -export default useAddToList; +export default useAddRead; diff --git a/services/hooks/read/use-delete-read.ts b/services/hooks/read/use-delete-read.ts new file mode 100644 index 00000000..492f7d2a --- /dev/null +++ b/services/hooks/read/use-delete-read.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import deleteRead from '@/services/api/read/deleteRead'; + +const useDeleteRead = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['delete-from-read-list'], + mutationFn: deleteRead, + onSuccess: async () => { + await queryClient.refetchQueries(); + }, + }); +}; + +export default useDeleteRead; diff --git a/services/hooks/read/use-following-read-list.ts b/services/hooks/read/use-following-read-list.ts new file mode 100644 index 00000000..e9481af3 --- /dev/null +++ b/services/hooks/read/use-following-read-list.ts @@ -0,0 +1,49 @@ +import getFollowingReadList, { + Params, +} from '@/services/api/read/getFollowingReadList'; +import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, + content_type: props.content_type, +}); + +export const key = (params: Params) => [ + 'following-read-list', + params.slug, + params.content_type, +]; + +const useFollowingReadList = (props: { + slug: string; + content_type: 'manga' | 'novel'; +}) => { + const params = paramsBuilder(props); + + return useInfiniteList({ + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getFollowingReadList({ + params, + page: pageParam, + }), + }); +}; + +export const prefetchFollowingReadList = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getFollowingReadList({ + params, + page: pageParam, + }), + }); +}; + +export default useFollowingReadList; diff --git a/services/hooks/read/use-read-list.ts b/services/hooks/read/use-read-list.ts new file mode 100644 index 00000000..06ff915e --- /dev/null +++ b/services/hooks/read/use-read-list.ts @@ -0,0 +1,71 @@ +import { QueryKey } from '@tanstack/react-query'; + +import getReadList, { Params } from '@/services/api/read/getReadList'; +import useInfiniteList from '@/services/hooks/use-infinite-list'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +export const paramsBuilder = ({ username, ...props }: Params): Params => ({ + username, + read_status: props.read_status || 'watching', + media_type: props.media_type || [], + status: props.status || [], + genres: props.genres || [], + magazines: props.magazines || [], + sort: props.sort || ['read_score:desc'], + years: props.years || [], + content_type: props.content_type || 'manga', +}); + +export const key = (params: Params): QueryKey => ['read-list', params]; + +const useReadList = ({ username, read_status, ...props }: Params) => { + const { titleLanguage } = useSettingsContext(); + + const params = paramsBuilder({ + read_status, + username, + ...props, + }); + + return useInfiniteList({ + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getReadList({ + params, + page: pageParam, + }), + select: (data) => ({ + ...data, + pages: data.pages.map((a) => ({ + ...a, + list: a.list.map((b) => ({ + ...b, + content: convertTitle({ + data: b.content, + titleLanguage: titleLanguage!, + }), + })), + })), + }), + }); +}; + +export const prefetchReadList = (props: Params) => { + const queryClient = getQueryClient(); + + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getReadList({ + params, + page: pageParam, + }), + }); +}; + +export default useReadList; diff --git a/services/hooks/read/use-read-stats.ts b/services/hooks/read/use-read-stats.ts new file mode 100644 index 00000000..d541c431 --- /dev/null +++ b/services/hooks/read/use-read-stats.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; + +import getReadStats, { Params } from '@/services/api/read/getReadStats'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + username: props.username, + content_type: props.content_type, +}); + +export const key = (params: Params) => [ + 'read-stats', + params.username, + params.content_type, +]; + +const useReadStats = (props: Params) => { + const params = paramsBuilder(props); + + return useQuery({ + queryKey: key(params), + queryFn: () => getReadStats({ params }), + }); +}; + +export const prefetchReadStats = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getReadStats({ params }), + }); +}; + +export default useReadStats; diff --git a/services/hooks/read/use-read.ts b/services/hooks/read/use-read.ts new file mode 100644 index 00000000..36796d4b --- /dev/null +++ b/services/hooks/read/use-read.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query'; + +import getRead, { Params } from '@/services/api/read/getRead'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, + content_type: props.content_type, +}); + +export const key = (params: Params) => [ + 'read', + params.slug, + params.content_type, +]; + +const useRead = (props: Params, options?: Hikka.QueryOptions) => { + const params = paramsBuilder(props); + + return useQuery({ + queryKey: key(params), + queryFn: () => getRead({ params }), + ...options, + }); +}; + +export const prefetchRead = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getRead({ params }), + }); +}; + +export default useRead; diff --git a/services/hooks/related/use-franchise.ts b/services/hooks/related/use-franchise.ts new file mode 100644 index 00000000..be1b09dc --- /dev/null +++ b/services/hooks/related/use-franchise.ts @@ -0,0 +1,61 @@ +import { QueryKey, useQuery } from '@tanstack/react-query'; + +import getFranchise, { Params } from '@/services/api/related/getFranchise'; +import { useSettingsContext } from '@/services/providers/settings-provider'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitleList } from '@/utils/title-adapter'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, + content_type: props.content_type, +}); + +export const key = (params: Params): QueryKey => [ + 'franchise', + params.slug, + params.content_type, +]; + +const useFranchise = (props: Params) => { + const { titleLanguage } = useSettingsContext(); + const params = paramsBuilder(props); + + return useQuery({ + queryKey: key(params), + queryFn: () => + getFranchise({ + params, + }), + select: (data) => ({ + list: [ + ...convertTitleList({ + data: data.anime, + titleLanguage: titleLanguage!, + }).flat(), + ...convertTitleList({ + data: data.manga, + titleLanguage: titleLanguage!, + }).flat(), + ...convertTitleList({ + data: data.novel, + titleLanguage: titleLanguage!, + }).flat(), + ], + }), + }); +}; + +export const prefetchFranchise = (props: Params) => { + const queryClient = getQueryClient(); + const params = paramsBuilder(props); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => + getFranchise({ + params, + }), + }); +}; + +export default useFranchise; diff --git a/services/hooks/settings/use-ignored-notifications.ts b/services/hooks/settings/use-ignored-notifications.ts index a80765c0..9aedb4a6 100644 --- a/services/hooks/settings/use-ignored-notifications.ts +++ b/services/hooks/settings/use-ignored-notifications.ts @@ -4,10 +4,12 @@ import getIgnoredNotifications, { Response, } from '@/services/api/settings/getIgnoredNotifications'; +export const key = () => ['ignored-notifications']; + const useIgnoredNotifications = (options?: UseQueryOptions<Response>) => { return useQuery({ ...options, - queryKey: ['ignoredNotifications'], + queryKey: key(), queryFn: () => getIgnoredNotifications(), }); }; diff --git a/services/hooks/stats/edit/use-edit-top.ts b/services/hooks/stats/edit/use-edit-top.ts index 07b94974..4859158b 100644 --- a/services/hooks/stats/edit/use-edit-top.ts +++ b/services/hooks/stats/edit/use-edit-top.ts @@ -1,9 +1,12 @@ import getEditTop from '@/services/api/stats/edit/getEditTop'; import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; -const useEditList = () => { +export const key = () => ['edit-top-stats']; + +const useEditTop = () => { return useInfiniteList({ - queryKey: ['editTopStats'], + queryKey: key(), queryFn: ({ pageParam }) => getEditTop({ page: pageParam, @@ -11,4 +14,17 @@ const useEditList = () => { }); }; -export default useEditList; +export const prefetchEditTop = () => { + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(), + queryFn: ({ pageParam = 1 }) => + getEditTop({ + page: pageParam, + }), + }); +}; + +export default useEditTop; diff --git a/services/hooks/stats/use-anime-schedule.ts b/services/hooks/stats/use-anime-schedule.ts index 580bc197..f691bc33 100644 --- a/services/hooks/stats/use-anime-schedule.ts +++ b/services/hooks/stats/use-anime-schedule.ts @@ -1,36 +1,29 @@ -import { useSearchParams } from 'next/navigation'; - -import getAnimeSchedule from '@/services/api/stats/getAnimeSchedule'; +import getAnimeSchedule, { + Params, +} from '@/services/api/stats/getAnimeSchedule'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; -import getCurrentSeason from '@/utils/get-current-season'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; + +const paramsBuilder = (props: Params) => ({ + airing_season: props.airing_season || undefined, + status: props.status || undefined, + only_watch: props.only_watch || undefined, +}); + +const key = (params: Params) => ['anime-schedule', params]; -const useAnimeSchedule = () => { +const useAnimeSchedule = (props: Params) => { const { titleLanguage } = useSettingsContext(); - const searchParams = useSearchParams(); - - const only_watch = searchParams.get('only_watch') - ? Boolean(searchParams.get('only_watch')) - : undefined; - const season = - (searchParams.get('season') as API.Season) || getCurrentSeason()!; - const year = searchParams.get('year') || String(new Date().getFullYear()); - const status = ( - searchParams.getAll('status').length > 0 - ? searchParams.getAll('status') - : ['ongoing', 'announced'] - ) as API.Status[]; + + const params = paramsBuilder(props); return useInfiniteList({ - queryKey: ['animeSchedule', { season, status, year, only_watch }], + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getAnimeSchedule({ - params: { - airing_season: [season, year], - status, - only_watch: only_watch, - }, + params, page: pageParam, }), select: (data) => ({ @@ -39,8 +32,8 @@ const useAnimeSchedule = () => { ...a, list: a.list.map((s) => ({ ...s, - anime: convertAnime<API.AnimeInfo>({ - anime: s.anime, + anime: convertTitle<API.AnimeInfo>({ + data: s.anime, titleLanguage: titleLanguage!, }), })), @@ -49,4 +42,19 @@ const useAnimeSchedule = () => { }); }; +export const prefetchAnimeSchedule = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getAnimeSchedule({ + params, + page: pageParam, + }), + }); +}; + export default useAnimeSchedule; diff --git a/services/hooks/user/use-user-activity.ts b/services/hooks/user/use-user-activity.ts index 11131617..b272b4a4 100644 --- a/services/hooks/user/use-user-activity.ts +++ b/services/hooks/user/use-user-activity.ts @@ -1,11 +1,30 @@ import { useQuery } from '@tanstack/react-query'; import getUserActivity, { Params } from '@/services/api/user/getUserActivity'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + username: props.username, +}); + +export const key = (params: Params) => ['activity-stats', params.username]; + +const useUserActivity = (props: Params) => { + const params = paramsBuilder(props); -const useUserActivity = ({ username }: Params) => { return useQuery({ - queryKey: ['activityStats', username], - queryFn: () => getUserActivity({ params: { username } }), + queryKey: key(params), + queryFn: () => getUserActivity({ params }), + }); +}; + +export const prefetchUserActivity = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getUserActivity({ params }), }); }; diff --git a/services/hooks/user/use-user-collections.ts b/services/hooks/user/use-user-collections.ts index c333daf6..d8678906 100644 --- a/services/hooks/user/use-user-collections.ts +++ b/services/hooks/user/use-user-collections.ts @@ -1,17 +1,42 @@ -import getCollections from '@/services/api/collections/getCollections'; +import getCollections, { + Params, +} from '@/services/api/collections/getCollections'; import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + author: props.author || undefined, + sort: props.sort || 'created', + only_public: props.only_public || false, + content_type: props.content_type || undefined, +}); + +export const key = (params: Params) => ['collections', params.author]; + +const useUserCollections = (props: Params) => { + const params = paramsBuilder(props); -const useUserCollections = ({ username }: { username: string }) => { return useInfiniteList({ - queryKey: ['collections', username], + queryKey: key(params), queryFn: ({ pageParam }) => getCollections({ page: pageParam, - params: { - sort: 'created', - only_public: false, - author: username, - }, + params, + }), + }); +}; + +export const prefetchUserCollections = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getCollections({ + page: pageParam, + params, }), }); }; diff --git a/services/hooks/user/use-user.ts b/services/hooks/user/use-user.ts index 53c55c15..0b34bd36 100644 --- a/services/hooks/user/use-user.ts +++ b/services/hooks/user/use-user.ts @@ -1,12 +1,30 @@ import { useQuery } from '@tanstack/react-query'; import getUserInfo, { Params } from '@/services/api/user/getUserInfo'; +import getQueryClient from '@/utils/get-query-client'; -const useUser = ({ username }: Params) => { +export const paramsBuilder = (props: Params): Params => ({ + username: props.username, +}); + +export const key = (params: Params) => ['user', params.username]; + +const useUser = (props: Params) => { + const params = paramsBuilder(props); return useQuery({ - queryKey: ['user', username], - queryFn: () => getUserInfo({ params: { username: String(username) } }), - enabled: !!username, + queryKey: key(params), + queryFn: () => getUserInfo({ params }), + enabled: !!params.username, + }); +}; + +export const prefetchUser = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getUserInfo({ params }), }); }; diff --git a/services/hooks/user/use-users.ts b/services/hooks/user/use-users.ts index 4f328cad..de798237 100644 --- a/services/hooks/user/use-users.ts +++ b/services/hooks/user/use-users.ts @@ -1,10 +1,32 @@ import { useQuery } from '@tanstack/react-query'; import getUsers, { Params } from '@/services/api/user/getUsers'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + query: props.query || undefined, +}); + +export const key = (params: Params) => ['users', params]; + +const useUsers = (props: Params) => { + const params = paramsBuilder(props); -const useUsers = (params: Params) => { return useQuery({ - queryKey: ['users', { ...params }], + queryKey: key(params), + queryFn: () => + getUsers({ + params, + }), + }); +}; + +export const prefetchUsers = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), queryFn: () => getUsers({ params, diff --git a/services/hooks/vote/useVote.ts b/services/hooks/vote/useVote.ts index 5371a6fa..b714cb8d 100644 --- a/services/hooks/vote/useVote.ts +++ b/services/hooks/vote/useVote.ts @@ -15,7 +15,7 @@ const useVote = () => { }); await queryClient.invalidateQueries({ queryKey: ['comments'] }); await queryClient.invalidateQueries({ - queryKey: ['commentThread'], + queryKey: ['comment-thread'], }); }, }); diff --git a/services/hooks/watch/use-add-watch.ts b/services/hooks/watch/use-add-watch.ts index c1f5be2c..4a7dd252 100644 --- a/services/hooks/watch/use-add-watch.ts +++ b/services/hooks/watch/use-add-watch.ts @@ -1,22 +1,34 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import addWatch from '@/services/api/watch/addWatch'; +import { useModalContext } from '@/services/providers/modal-provider'; const useAddWatch = () => { + const { closeModal } = useModalContext(); const queryClient = useQueryClient(); return useMutation({ mutationKey: ['addToList'], mutationFn: addWatch, onSettled: async () => { - await queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: ['anime-list'] }); + await queryClient.refetchQueries({ queryKey: ['watch'], exact: false, }); await queryClient.invalidateQueries({ - queryKey: ['watchList'], + queryKey: ['watch-list'], exact: false, }); + await queryClient.invalidateQueries({ queryKey: ['favorites'] }); + await queryClient.invalidateQueries({ queryKey: ['franchise'] }); + await queryClient.invalidateQueries({ queryKey: ['collection'] }); + await queryClient.invalidateQueries({ + queryKey: ['anime-schedule', {}], + exact: false, + }); + + closeModal(); }, }); }; diff --git a/services/hooks/watch/use-delete-from-list.tsx b/services/hooks/watch/use-delete-from-list.tsx deleted file mode 100644 index f57bcfe0..00000000 --- a/services/hooks/watch/use-delete-from-list.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import deleteWatch from '@/services/api/watch/deleteWatch'; - -const useDeleteFromList = ({ slug }: { slug: string }) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationKey: ['deleteFromList', slug], - mutationFn: () => - deleteWatch({ - params: { - slug, - }, - }), - onSuccess: async () => { - await queryClient.refetchQueries({ - queryKey: ['watch', slug], - }); - await queryClient.invalidateQueries({ queryKey: ['list'] }); - await queryClient.invalidateQueries({ - queryKey: ['watchList'], - exact: false, - }); - await queryClient.invalidateQueries({ queryKey: ['favorites'] }); - await queryClient.invalidateQueries({ queryKey: ['franchise'] }); - await queryClient.invalidateQueries({ queryKey: ['collection'] }); - await queryClient.invalidateQueries({ - queryKey: ['animeSchedule', {}], - exact: false, - }); - }, - }); -}; - -export default useDeleteFromList; diff --git a/services/hooks/watch/use-delete-watch.ts b/services/hooks/watch/use-delete-watch.ts new file mode 100644 index 00000000..4b818366 --- /dev/null +++ b/services/hooks/watch/use-delete-watch.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import deleteWatch from '@/services/api/watch/deleteWatch'; + +const useDeleteFromList = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['deleteFromList'], + mutationFn: deleteWatch, + onSuccess: async () => { + await queryClient.refetchQueries(); + }, + }); +}; + +export default useDeleteFromList; diff --git a/services/hooks/watch/use-following-watch-list.ts b/services/hooks/watch/use-following-watch-list.ts index 1d10106f..6701d2de 100644 --- a/services/hooks/watch/use-following-watch-list.ts +++ b/services/hooks/watch/use-following-watch-list.ts @@ -1,12 +1,38 @@ -import getFollowingWatchList from '@/services/api/watch/getFollowingWatchList'; +import getFollowingWatchList, { + Params, +} from '@/services/api/watch/getFollowingWatchList'; import useInfiniteList from '@/services/hooks/use-infinite-list'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['following-watch-list', params.slug]; + +const useFollowingWatchList = (props: Params) => { + const params = paramsBuilder(props); -const useFollowingWatchList = ({ slug }: { slug: string }) => { return useInfiniteList({ - queryKey: ['followingWatchList', slug], + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getFollowingWatchList({ + params, + page: pageParam, + }), + }); +}; + +export const prefetchFollowingWatchList = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getFollowingWatchList({ - params: { slug }, + params, page: pageParam, }), }); diff --git a/services/hooks/watch/use-watch-list.ts b/services/hooks/watch/use-watch-list.ts index 77c4beb1..ce6bcc66 100644 --- a/services/hooks/watch/use-watch-list.ts +++ b/services/hooks/watch/use-watch-list.ts @@ -1,62 +1,40 @@ -import { useSearchParams } from 'next/navigation'; +import { QueryKey } from '@tanstack/react-query'; -import getWatchList from '@/services/api/watch/getWatchList'; +import getWatchList, { Params } from '@/services/api/watch/getWatchList'; import useInfiniteList from '@/services/hooks/use-infinite-list'; import { useSettingsContext } from '@/services/providers/settings-provider'; -import { convertAnime } from '@/utils/anime-adapter'; +import getQueryClient from '@/utils/get-query-client'; +import { convertTitle } from '@/utils/title-adapter'; -const useWatchList = ({ +export const paramsBuilder = ({ username, ...props }: Params): Params => ({ username, - watch_status, -}: { - username: string; - watch_status: API.WatchStatus; -}) => { - const { titleLanguage } = useSettingsContext(); - const searchParams = useSearchParams(); + watch_status: props.watch_status || 'watching', + media_type: props.media_type || [], + status: props.status || [], + season: props.season || [], + rating: props.rating || [], + genres: props.genres || [], + studios: props.studios || [], + sort: props.sort || ['watch_score:desc'], + years: props.years || [], +}); + +export const key = (params: Params): QueryKey => ['watch-list', params]; - const types = searchParams.getAll('types'); - const statuses = searchParams.getAll('statuses'); - const seasons = searchParams.getAll('seasons'); - const ageRatings = searchParams.getAll('ratings'); - const years = searchParams.getAll('years'); - const genres = searchParams.getAll('genres'); - const studios = searchParams.getAll('studios'); +const useWatchList = ({ username, watch_status, ...props }: Params) => { + const { titleLanguage } = useSettingsContext(); - const order = searchParams.get('order') || 'desc'; - const sort = searchParams.get('sort') || 'watch_score'; + const params = paramsBuilder({ + watch_status, + username, + ...props, + }); return useInfiniteList({ - queryKey: [ - 'watchList', - username, - { - watch_status, - types, - statuses, - seasons, - ageRatings, - genres, - studios, - order, - sort, - years, - }, - ], + queryKey: key(params), queryFn: ({ pageParam = 1 }) => getWatchList({ - params: { - username: username, - watch_status: watch_status, - media_type: types, - season: seasons, - rating: ageRatings, - status: statuses, - studios: studios, - sort: [`${sort}:${order}`], - genres, - years: years && years.length == 2 ? years : undefined, - }, + params, page: pageParam, }), select: (data) => ({ @@ -65,8 +43,8 @@ const useWatchList = ({ ...a, list: a.list.map((b) => ({ ...b, - anime: convertAnime<API.Anime>({ - anime: b.anime, + anime: convertTitle<API.Anime>({ + data: b.anime, titleLanguage: titleLanguage!, }), })), @@ -75,4 +53,20 @@ const useWatchList = ({ }); }; +export const prefetchWatchList = (props: Params) => { + const queryClient = getQueryClient(); + + const params = paramsBuilder(props); + + return queryClient.prefetchInfiniteQuery({ + initialPageParam: 1, + queryKey: key(params), + queryFn: ({ pageParam = 1 }) => + getWatchList({ + params, + page: pageParam, + }), + }); +}; + export default useWatchList; diff --git a/services/hooks/watch/use-watch-stats.ts b/services/hooks/watch/use-watch-stats.ts index e1caa305..c25df851 100644 --- a/services/hooks/watch/use-watch-stats.ts +++ b/services/hooks/watch/use-watch-stats.ts @@ -1,11 +1,30 @@ import { useQuery } from '@tanstack/react-query'; import getWatchStats, { Params } from '@/services/api/watch/getWatchStats'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + username: props.username, +}); + +export const key = (params: Params) => ['watch-stats', params.username]; + +const useWatchStats = (props: Params) => { + const params = paramsBuilder(props); -const useWatchStats = ({ username }: Params) => { return useQuery({ - queryKey: ['watchStats', username], - queryFn: () => getWatchStats({ params: { username } }), + queryKey: key(params), + queryFn: () => getWatchStats({ params }), + }); +}; + +export const prefetchWatchStats = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getWatchStats({ params }), }); }; diff --git a/services/hooks/watch/use-watch.ts b/services/hooks/watch/use-watch.ts index 7dc0b0bf..1f206c21 100644 --- a/services/hooks/watch/use-watch.ts +++ b/services/hooks/watch/use-watch.ts @@ -1,13 +1,32 @@ import { useQuery } from '@tanstack/react-query'; import getWatch, { Params } from '@/services/api/watch/getWatch'; +import getQueryClient from '@/utils/get-query-client'; + +export const paramsBuilder = (props: Params): Params => ({ + slug: props.slug, +}); + +export const key = (params: Params) => ['watch', params.slug]; + +const useWatch = (props: Params, options?: Hikka.QueryOptions) => { + const params = paramsBuilder(props); -const useWatch = ({ slug }: Params, options?: Hikka.QueryOptions) => { return useQuery({ - queryKey: ['watch', slug], - queryFn: () => getWatch({ params: { slug } }), + queryKey: key(params), + queryFn: () => getWatch({ params }), ...options, }); }; +export const prefetchWatch = (props: Params) => { + const params = paramsBuilder(props); + const queryClient = getQueryClient(); + + return queryClient.prefetchQuery({ + queryKey: key(params), + queryFn: () => getWatch({ params }), + }); +}; + export default useWatch; diff --git a/services/providers/collection-provider.tsx b/services/providers/collection-provider.tsx index 694ff936..2483a07d 100644 --- a/services/providers/collection-provider.tsx +++ b/services/providers/collection-provider.tsx @@ -16,7 +16,7 @@ import { export type Item = { id: string | number; - content: API.MainContent; + content: API.MainContent & { title?: string }; }; export type Group = { diff --git a/services/providers/modal-provider.tsx b/services/providers/modal-provider.tsx index 9b8b83c8..c3f168af 100644 --- a/services/providers/modal-provider.tsx +++ b/services/providers/modal-provider.tsx @@ -21,6 +21,7 @@ import { DrawerHeader, DrawerTitle, } from '@/components/ui/drawer'; +import { Separator } from '@/components/ui/separator'; import { Sheet, SheetContent, @@ -149,16 +150,14 @@ export default function ModalProvider({ children }: Props) { onOpenChange={(open) => setState({ ...state, open })} > <DrawerContent - className={cn( - 'max-h-[90dvh] p-4 pt-0', - state.className, - )} + className={cn('max-h-[90dvh]', state.className)} > {state.title && ( - <DrawerHeader className="px-0 text-left"> + <DrawerHeader> <DrawerTitle>{state.title}</DrawerTitle> </DrawerHeader> )} + <Separator /> {state.content} </DrawerContent> </Drawer> @@ -169,15 +168,16 @@ export default function ModalProvider({ children }: Props) { <SheetContent side={state.side} className={cn( - 'flex !max-w-lg flex-col gap-0 pb-0', + 'flex !max-w-lg flex-col gap-0 p-0', state.className, )} > {state.title && ( - <SheetHeader> + <SheetHeader className="px-6 py-4"> <SheetTitle>{state.title}</SheetTitle> </SheetHeader> )} + <Separator /> {state.content} </SheetContent> </Sheet> @@ -185,13 +185,7 @@ export default function ModalProvider({ children }: Props) { {(isDesktop || state.forceModal) && state.type === 'dialog' && ( <Dialog open={state.open} onOpenChange={closeModal}> - <DialogContent - className={cn( - 'no-scrollbar max-h-[90dvh] overflow-y-scroll', - state.className, - )} - containerClassName={state.containerClassName} - > + <DialogContent className={cn(state.className)}> {state.title && ( <DialogHeader> <DialogTitle>{state.title}</DialogTitle> diff --git a/tailwind.config.js b/tailwind.config.js index b7c23356..5ffc1c12 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -21,10 +21,10 @@ module.exports = { }, extend: { gridAutoColumns: { - scroll: 'minmax(var(--grid-min, 7rem), 1fr)', + scroll: 'minmax(var(--grid-min, 7rem), var(--grid-max, 1fr))', }, gridTemplateColumns: { - scroll: 'repeat(auto-fill, minmax(var(--grid-min, 7rem), 1fr))', + scroll: 'repeat(auto-fill, minmax(var(--grid-min, 7rem), var(--grid-max, 1fr)))', }, fontFamily: { sans: ['var(--font-inter)'], diff --git a/tsconfig.json b/tsconfig.json index 8ebf0783..4c972d93 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ "lib": ["dom", "dom.iterable", "esnext"] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".next"] } diff --git a/types/api.d.ts b/types/api.d.ts index 50c101a0..ee29adff 100644 --- a/types/api.d.ts +++ b/types/api.d.ts @@ -16,8 +16,16 @@ declare global { | 'dropped' | 'on_hold'; + type ReadStatus = + | 'completed' + | 'reading' + | 'on_hold' + | 'dropped' + | 'planned'; + type StatType = - | WatchStatus + | API.ReadStatus + | API.WatchStatus | 'score_1' | 'score_2' | 'score_3' @@ -31,11 +39,31 @@ declare global { type Season = 'summer' | 'winter' | 'fall' | 'spring'; - type MediaType = 'tv' | 'movie' | 'ova' | 'ona' | 'special' | 'music'; + type MangaMediaType = + | 'one_shot' + | 'doujin' + | 'manhua' + | 'manhwa' + | 'manga'; + + type AnimeMediaType = + | 'tv' + | 'movie' + | 'ova' + | 'ona' + | 'special' + | 'music'; + + type NovelMediaType = 'light_novel' | 'novel'; + + type MediaType = + | API.MangaMediaType + | API.AnimeMediaType + | API.NovelMediaType; type AgeRating = 'g' | 'pg' | 'pg_13' | 'r' | 'r_plus' | 'rx'; - type Status = 'ongoing' | 'finished' | 'announced'; + type Status = 'ongoing' | 'finished' | 'announced' | 'paused'; type VideoType = 'video_promo' | 'video_music'; @@ -61,6 +89,8 @@ declare global { type EditStatus = 'pending' | 'accepted' | 'denied' | 'closed'; type ContentType = + | 'novel' + | 'manga' | 'edit' | 'anime' | 'character' @@ -71,9 +101,18 @@ declare global { type HistoryType = | 'watch' | 'watch_delete' + | 'read_novel' + | 'read_novel_delete' + | 'read_manga' + | 'read_manga_delete' | 'watch_import' + | 'read_import' | 'favourite_anime_add' - | 'favourite_anime_remove'; + | 'favourite_anime_remove' + | 'favourite_manga_add' + | 'favourite_manga_remove' + | 'favourite_novel_add' + | 'favourite_novel_remove'; type Error = { code: string; @@ -98,7 +137,7 @@ declare global { list: T[]; }; - type Stats = Record<StatType, number>; + type Stats = Record<API.StatType, number>; type Pagination = { total: number; @@ -133,6 +172,19 @@ declare global { anime: API.Anime; }; + type Read = { + reference: string; + note: string; + updated: number; + created: number; + status: API.ReadStatus; + chapters: number; + volumes: number; + rereads: number; + score: number; + content: API.Manga | API.Novel; + }; + type Schedule = { episode: number; airing_at: number; @@ -145,13 +197,13 @@ declare global { type Anime = { data_type: 'anime'; - media_type: API.MediaType; + media_type: API.AnimeMediaType; title_ua: string; title_en: string; title_ja: string; episodes_released: number; episodes_total: number; - poster: string; + image: string; status: API.Status; scored_by: number; score: number; @@ -193,6 +245,99 @@ declare global { type: API.GenreType; }; + type Magazine = { + name_en: string; + slug: string; + }; + + type Manga = { + data_type: 'manga'; + title?: string; + title_original: string; + media_type: API.MangaMediaType; + title_ua: string; + title_en: string; + chapters: number; + volumes: number; + translated_ua: boolean; + status: API.Status; + image: string; + year: number; + scored_by: number; + score: number; + slug: string; + read: API.Read[]; + }; + + type MangaInfo = { + authors: { + person: API.Person; + roles: { + name_ua: string; + name_en: string; + slug: string; + }[]; + }[]; + magazines: API.Magazine[]; + external: API.External[]; + start_date: number; + end_date: number; + genres: API.Genre[]; + stats: API.Stats; + synopsis_en: string; + synopsis_ua: string; + updated: number; + synonyms: string[]; + comments_count: number; + has_franchise: boolean; + mal_id: number; + nsfw: boolean; + } & API.Manga; + + type Novel = { + data_type: 'novel'; + title?: string; + title_original: string; + media_type: API.NovelMediaType; + title_ua: string; + title_en: string; + chapters: number; + volumes: number; + translated_ua: boolean; + status: API.Status; + image: string; + year: number; + scored_by: number; + score: number; + slug: string; + read: API.Read[]; + }; + + type NovelInfo = { + authors: { + person: API.Person; + roles: { + name_ua: string; + name_en: string; + slug: string; + }[]; + }[]; + magazines: API.Magazine[]; + external: API.External[]; + start_date: number; + end_date: number; + genres: API.Genre[]; + stats: API.Stats; + synopsis_en: string; + synopsis_ua: string; + updated: number; + synonyms: string[]; + comments_count: number; + has_franchise: boolean; + mal_id: number; + nsfw: boolean; + } & API.Novel; + type Character = { data_type: 'character'; name_ua: string; @@ -247,6 +392,7 @@ declare global { reference: string; author: API.User; created: number; + updated: number; text: string; replies: API.Comment[]; total_replies: number; @@ -254,26 +400,34 @@ declare global { vote_score: number; my_score?: number; hidden: boolean; + is_editable: boolean; parent: string | null; - }; - - type GlobalComment = { - author: API.User; - updated: number; - created: number; content_type: API.ContentType; - image: string; - text: string; - vote_score: number; - reference: string; - depth: number; - slug: string; + preview: { slug: string; image?: string }; }; type External = { url: string; text: string; - type: 'general' | 'watch'; + type: 'general' | 'watch' | 'read'; + }; + + type HistoryReadData = { + after: { + score: number | null; + status: API.ReadStatus | null; + chapters: number | null; + volumes: number | null; + rereads: number | null; + }; + before: { + score: number | null; + status: API.ReadStatus | null; + chapters: number | null; + volumes: number | null; + rereads: number | null; + }; + new_read: boolean; }; type HistoryWatchData = { @@ -294,15 +448,25 @@ declare global { type HistoryFavoriteData = {}; - type HistoryImportData = { + type HistoryWatchImportData = { imported: number; }; + type HistoryReadImportData = { + imported_manga: number; + imported_novel: number; + }; + type History< - TData = HistoryWatchData | HistoryFavoriteData | HistoryImportData, + TData = + | HistoryWatchData + | HistoryFavoriteData + | HistoryWatchImportData + | HistoryReadImportData + | HistoryReadData, > = { reference: string; - content?: API.Anime; + content?: API.Anime | API.Manga | API.Novel; history_type: API.HistoryType; created: number; updated: number; @@ -378,7 +542,7 @@ declare global { status: API.Status; episodes_released: number; }; - poster: string; + image: string; title_en: string; title_ja: string; title_ua: string; @@ -414,7 +578,9 @@ declare global { order: number; }; - type Collection<TContent extends API.MainContent = unknown> = { + type Collection< + TContent extends API.MainContent & { title?: string } = unknown, + > = { data_type: 'collection'; author: API.User; created: number; @@ -436,7 +602,11 @@ declare global { type Content = | API.Anime + | API.Manga + | API.Novel | API.AnimeInfo + | API.MangaInfo + | API.NovelInfo | API.Character | API.Person | API.Collection; diff --git a/types/hikka.d.ts b/types/hikka.d.ts index 126d447a..f89320f4 100644 --- a/types/hikka.d.ts +++ b/types/hikka.d.ts @@ -56,6 +56,28 @@ declare global { }[]; }; + type MangaEditParams = { + title_ua?: string; + title_en?: string; + title_original?: string; + synopsis_en?: string; + synopsis_ua?: string; + synonyms?: { + value: string; + }[]; + }; + + type NovelEditParams = { + title_ua?: string; + title_en?: string; + title_original?: string; + synopsis_en?: string; + synopsis_ua?: string; + synonyms?: { + value: string; + }[]; + }; + type CharacterEditParams = { name_ua: string; name_en: string; @@ -78,10 +100,10 @@ declare global { created: number; href: string; seen: boolean; - poster?: ReactNode; + image?: ReactNode; }; - type WatchStat = { + type ListStat = { percentage: number; value: number; icon?: ReactNode; diff --git a/utils/anime-adapter.ts b/utils/anime-adapter.ts deleted file mode 100644 index c1d11428..00000000 --- a/utils/anime-adapter.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const convertAnime = <TData extends API.AnimeInfo | API.Anime>({ - titleLanguage, - anime, -}: { - titleLanguage: 'title_en' | 'title_ua' | 'title_ja'; - anime: TData; -}): TData => { - return { - ...anime, - title: - anime[titleLanguage] || - anime.title_ua || - anime.title_en || - anime.title_ja, - }; -}; - -export const convertAnimeList = <TData extends API.AnimeInfo | API.Anime>({ - anime, - titleLanguage, -}: { - anime: TData[]; - titleLanguage: 'title_en' | 'title_ua' | 'title_ja'; -}): TData[] => { - return anime.map((anime) => convertAnime({ titleLanguage, anime })); -}; diff --git a/utils/constants.ts b/utils/constants.ts index 41256987..6713f1b4 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -1,7 +1,11 @@ -import MaterialSymbolsLightGridViewRounded from '~icons/material-symbols-light/grid-view-rounded'; +import MaterialSymbolsBookmarkFlagOutlineRounded from '~icons/material-symbols/bookmark-flag-outline-rounded'; +import MaterialSymbolsBookmarkOutline from '~icons/material-symbols/bookmark-outline'; import MaterialSymbolsCalendarClockRounded from '~icons/material-symbols/calendar-clock-rounded'; import MaterialSymbolsEditRounded from '~icons/material-symbols/edit-rounded'; import MaterialSymbolsHomeRounded from '~icons/material-symbols/home-rounded'; +import MaterialSymbolsMenuBookRounded from '~icons/material-symbols/menu-book-rounded'; +import MaterialSymbolsPalette from '~icons/material-symbols/palette'; +import MaterialSymbolsPlayArrowRounded from '~icons/material-symbols/play-arrow-rounded'; import MaterialSymbolsStack from '~icons/material-symbols/stack'; import Completed from '@/components/icons/watch-status/completed'; @@ -10,6 +14,39 @@ import OnHold from '@/components/icons/watch-status/on-hold'; import Planned from '@/components/icons/watch-status/planned'; import Watching from '@/components/icons/watch-status/watching'; +export const READ_STATUS: Hikka.FilterProperty<API.ReadStatus> = { + planned: { + title_ua: 'Заплановано', + title_en: 'Planned', + icon: Planned, + color: '#AB872B', + }, + completed: { + title_ua: 'Завершено', + title_en: 'Completed', + icon: Completed, + color: '#399A54', + }, + on_hold: { + title_ua: 'Відкладено', + title_en: 'On Hold', + icon: MaterialSymbolsBookmarkFlagOutlineRounded, + color: '#5C5C5C', + }, + dropped: { + title_ua: 'Закинуто', + title_en: 'Dropped', + icon: Dropped, + color: '#952828', + }, + reading: { + title_ua: 'Читаю', + title_en: 'Reading', + icon: MaterialSymbolsBookmarkOutline, + color: '#2B94AB', + }, +}; + export const WATCH_STATUS: Hikka.FilterProperty<API.WatchStatus> = { planned: { title_ua: 'Заплановано', @@ -97,14 +134,14 @@ export const RELEASE_STATUS: Hikka.FilterProperty<API.Status> = { color: '#AB872B', }, - /*paused: { + paused: { title_ua: 'Зупинено', title_en: 'Paused', color: '#5C5C5C', - },*/ + }, }; -export const MEDIA_TYPE: Hikka.FilterProperty<API.MediaType> = { +export const ANIME_MEDIA_TYPE: Hikka.FilterProperty<API.AnimeMediaType> = { special: { title_ua: 'Спешл', title_en: 'Special', @@ -131,6 +168,40 @@ export const MEDIA_TYPE: Hikka.FilterProperty<API.MediaType> = { }, }; +export const MANGA_MEDIA_TYPE: Hikka.FilterProperty<API.MangaMediaType> = { + one_shot: { + title_ua: 'Ваншот', + title_en: 'One Shot', + }, + doujin: { + title_ua: 'Доджінші', + title_en: 'Doujin', + }, + manhua: { + title_ua: 'Манхуа', + title_en: 'Manhua', + }, + manhwa: { + title_ua: 'Манхва', + title_en: 'Manhwa', + }, + manga: { + title_ua: 'Манґа', + title_en: 'Manga', + }, +}; + +export const NOVEL_MEDIA_TYPE: Hikka.FilterProperty<API.NovelMediaType> = { + light_novel: { + title_ua: 'Ранобе', + title_en: 'Light Novel', + }, + novel: { + title_ua: 'Веб-новела', + title_en: 'Novel', + }, +}; + export const AGE_RATING: Hikka.FilterProperty<API.AgeRating> = { g: { title_ua: 'G', @@ -393,9 +464,23 @@ export const GENERAL_NAV_ROUTES: Hikka.NavRoute[] = [ }, { slug: 'anime', - title_ua: 'Каталог', + title_ua: 'Аніме', url: '/anime', - icon: MaterialSymbolsLightGridViewRounded, + icon: MaterialSymbolsPlayArrowRounded, + visible: true, + }, + { + slug: 'manga', + title_ua: 'Манґа', + url: '/manga', + icon: MaterialSymbolsPalette, + visible: true, + }, + { + slug: 'novel', + title_ua: 'Ранобе', + url: '/novel', + icon: MaterialSymbolsMenuBookRounded, visible: true, }, { @@ -445,6 +530,32 @@ export const GENERAL_NAV_ROUTES: Hikka.NavRoute[] = [ }, ]; +export const NOVEL_NAV_ROUTES: Hikka.NavRoute[] = [ + { + slug: 'general', + title_ua: 'Загальне', + url: '', + }, + { + slug: 'characters', + title_ua: 'Персонажі', + url: '/characters', + }, +]; + +export const MANGA_NAV_ROUTES: Hikka.NavRoute[] = [ + { + slug: 'general', + title_ua: 'Загальне', + url: '', + }, + { + slug: 'characters', + title_ua: 'Персонажі', + url: '/characters', + }, +]; + export const ANIME_NAV_ROUTES: Hikka.NavRoute[] = [ { slug: 'general', @@ -517,8 +628,18 @@ export const USER_NAV_ROUTES: Hikka.NavRoute[] = [ }, { slug: 'list', - title_ua: 'Список', - url: '/list', + title_ua: 'Список аніме', + url: '/list/anime', + }, + { + slug: 'list', + title_ua: 'Список манґи', + url: '/list/manga', + }, + { + slug: 'list', + title_ua: 'Список ранобе', + url: '/list/novel', }, { slug: 'favorites', @@ -592,8 +713,114 @@ export const ANIME_EDIT_GROUPS: Record<string, string> = { synonyms: 'Синоніми', }; +export const MANGA_EDIT_PARAMS: Record<string, Hikka.EditParam[]> = { + title: [ + { + slug: 'title_ua', + title: 'Українською', + placeholder: 'Введіть назву українською', + type: 'input', + }, + { + slug: 'title_en', + title: 'Англійською', + placeholder: 'Введіть назву англійською', + type: 'input', + }, + { + slug: 'title_original', + title: 'Японською', + placeholder: 'Введіть назву японською', + type: 'input', + }, + ], + + synopsis: [ + { + slug: 'synopsis_ua', + title: 'Українською', + placeholder: 'Введіть опис українською', + type: 'markdown', + }, + { + slug: 'synopsis_en', + title: 'Англійською', + placeholder: 'Введіть опис англійською', + type: 'markdown', + }, + ], + synonyms: [ + { + slug: 'synonyms', + title: 'Синонім', + placeholder: 'Введіть новий синонім', + type: 'list', + }, + ], +}; + +export const MANGA_EDIT_GROUPS: Record<string, string> = { + title: 'Назва', + synopsis: 'Опис', + synonyms: 'Синоніми', +}; + +export const NOVEL_EDIT_PARAMS: Record<string, Hikka.EditParam[]> = { + title: [ + { + slug: 'title_ua', + title: 'Українською', + placeholder: 'Введіть назву українською', + type: 'input', + }, + { + slug: 'title_en', + title: 'Англійською', + placeholder: 'Введіть назву англійською', + type: 'input', + }, + { + slug: 'title_original', + title: 'Японською', + placeholder: 'Введіть назву японською', + type: 'input', + }, + ], + + synopsis: [ + { + slug: 'synopsis_ua', + title: 'Українською', + placeholder: 'Введіть опис українською', + type: 'markdown', + }, + { + slug: 'synopsis_en', + title: 'Англійською', + placeholder: 'Введіть опис англійською', + type: 'markdown', + }, + ], + synonyms: [ + { + slug: 'synonyms', + title: 'Синонім', + placeholder: 'Введіть новий синонім', + type: 'list', + }, + ], +}; + +export const NOVEL_EDIT_GROUPS: Record<string, string> = { + title: 'Назва', + synopsis: 'Опис', + synonyms: 'Синоніми', +}; + export const EDIT_PARAMS: Record< | keyof Hikka.AnimeEditParams + | keyof Hikka.MangaEditParams + | keyof Hikka.NovelEditParams | keyof Hikka.CharacterEditParams | keyof Hikka.PersonEditParams, string @@ -606,6 +833,7 @@ export const EDIT_PARAMS: Record< title_ua: 'Назва UA', title_en: 'Назва EN', title_ja: 'Назва JA', + title_original: 'Назва JA', synopsis_ua: 'Опис UA', synopsis_en: 'Опис EN', name_native: 'Рідне імʼя', @@ -695,7 +923,7 @@ export const PERSON_EDIT_GROUPS: Record<string, string> = { synonyms: 'Синоніми', }; -export const CONTENT_TYPES: Hikka.FilterProperty<API.ContentType> = { +export const CONTENT_TYPES: Hikka.FilterProperty<API.ContentType | 'user'> = { anime: { title_ua: 'Аніме', title_en: 'Anime', @@ -720,6 +948,18 @@ export const CONTENT_TYPES: Hikka.FilterProperty<API.ContentType> = { title_ua: 'Колекція', title_en: 'Collection', }, + manga: { + title_ua: 'Манґа', + title_en: 'Manga', + }, + novel: { + title_ua: 'Ранобе', + title_en: 'Ranobe', + }, + user: { + title_ua: 'Користувач', + title_en: 'User', + }, }; export const EDIT_STATUSES: Hikka.FilterProperty<API.EditStatus> = { @@ -752,6 +992,8 @@ export const CONTENT_TYPE_LINKS: Record<API.ContentType, string> = { edit: '/edit', comment: '/comments', collection: '/collections', + manga: '/manga', + novel: '/novel', }; export const COLLECTION_CONTENT_TYPE_OPTIONS = [ @@ -759,6 +1001,14 @@ export const COLLECTION_CONTENT_TYPE_OPTIONS = [ value: 'anime', label: 'Аніме', }, + { + value: 'manga', + label: 'Манґа', + }, + { + value: 'novel', + label: 'Ранобе', + }, { value: 'character', label: 'Персонаж', diff --git a/utils/convert-activity/convert-favorite-activity.tsx b/utils/convert-activity/convert-favorite-activity.tsx index 8e9bdbfb..6d4629db 100644 --- a/utils/convert-activity/convert-favorite-activity.tsx +++ b/utils/convert-activity/convert-favorite-activity.tsx @@ -9,11 +9,11 @@ export const convertAddFavorite = () => { export const createFavoriteEvents = (history_type: API.HistoryType) => { const events = []; - if (history_type === 'favourite_anime_remove') { + if (history_type.includes('_remove')) { events.push(convertDeleteFavorite()); } - if (history_type === 'favourite_anime_add') { + if (history_type.includes('_add')) { events.push(convertAddFavorite()); } diff --git a/utils/convert-activity/convert-import-activity.tsx b/utils/convert-activity/convert-import-activity.tsx index 02079eb9..d944c492 100644 --- a/utils/convert-activity/convert-import-activity.tsx +++ b/utils/convert-activity/convert-import-activity.tsx @@ -1,11 +1,24 @@ -export const convertImportWatch = (imported: number) => { - return `Імпортовано **${imported}** аніме у список`; +export const convertImportWatch = (data: API.HistoryWatchImportData) => { + return `Імпортовано **${data.imported}** аніме у список`; }; -export const createImportEvents = (data: API.HistoryImportData) => { +export const convertImportRead = (data: API.HistoryReadImportData) => { + return `Імпортовано **${data.imported_manga}** манґи та **${data.imported_novel}** ранобе у список`; +}; + +export const createImportEvents = ( + history_type: API.HistoryType, + data: API.HistoryWatchImportData | API.HistoryReadImportData, +) => { const events = []; - events.push(convertImportWatch(data.imported)); + if (history_type === 'watch_import') { + events.push(convertImportWatch(data as API.HistoryWatchImportData)); + } + + if (history_type === 'read_import') { + events.push(convertImportRead(data as API.HistoryReadImportData)); + } return events; }; diff --git a/utils/convert-activity/convert-read-activity.tsx b/utils/convert-activity/convert-read-activity.tsx new file mode 100644 index 00000000..7bdd1ab4 --- /dev/null +++ b/utils/convert-activity/convert-read-activity.tsx @@ -0,0 +1,126 @@ +import { READ_STATUS } from '@/utils/constants'; +import getDeclensionWord from '@/utils/get-declension-word'; + +const CHAPTERS_DECLENSION: [string, string, string] = [ + 'розділ', + 'розділи', + 'розділів', +]; +const VOLUMES_DECLENSION: [string, string, string] = ['том', 'томи', 'томів']; +const TIMES_DECLENSION: [string, string, string] = ['раз', 'рази', 'разів']; + +export const convertStatus = ( + before: API.ReadStatus | null, + after: API.ReadStatus | null, +) => { + if (before === null && after) { + return `${READ_STATUS[after].title_ua}`; + } + + if (before !== null && after) { + return `Змінено на список **${READ_STATUS[after].title_ua}**`; + } + + if (before && after === null) { + return 'Видалено зі списоку'; + } +}; + +export const convertScore = (before: number | null, after: number | null) => { + if (before === null && after !== null) { + return `Оцінено на **${after}**`; + } + + if (before !== null && after !== null) { + if (before === after || before === 0) { + return `Оцінено на **${after}**`; + } + + return `Змінено оцінку з **${before}** на **${after}**`; + } +}; + +export const convertChapters = ( + before: number | null, + after: number | null, +) => { + if (before === null && after !== null) { + return `Прочитано **${after}** ${getDeclensionWord(after, CHAPTERS_DECLENSION)}`; + } + + if (before !== null && after !== null) { + if (before === after) { + return `Прочитано **${after}** ${getDeclensionWord(after, CHAPTERS_DECLENSION)}`; + } else if (after === 0) { + return null; + } else if (after - before === 1 || before === 0 || before > after) { + return `Прочитано **${after}** ${CHAPTERS_DECLENSION[0]}`; + } else { + return `Прочитано з **${before + 1}** по **${after}** ${CHAPTERS_DECLENSION[0]}`; + } + } +}; + +export const convertVolumes = (before: number | null, after: number | null) => { + if (before === null && after !== null) { + return `Прочитано **${after}** ${getDeclensionWord(after, VOLUMES_DECLENSION)}`; + } + + if (before !== null && after !== null) { + if (before === after) { + return `Прочитано **${after}** ${getDeclensionWord(after, VOLUMES_DECLENSION)}`; + } else if (after === 0) { + return null; + } else if (after - before === 1 || before === 0 || before > after) { + return `Прочитано **${after}** ${VOLUMES_DECLENSION[0]}`; + } else { + return `Прочитано з **${before + 1}** по **${after}** ${VOLUMES_DECLENSION[0]}`; + } + } +}; + +export const convertRereads = (before: number | null, after: number | null) => { + if (after !== null) { + return `Повторно прочитано **${after}** ${getDeclensionWord(after, TIMES_DECLENSION)}`; + } +}; + +export const convertDeleteRead = () => { + return 'Видалено зі списку'; +}; + +export const createReadEvents = ( + history_type: API.HistoryType, + data?: API.HistoryReadData, +) => { + const events = []; + + if ( + history_type === 'read_manga_delete' || + history_type === 'read_novel_delete' + ) { + events.push(convertDeleteRead()); + } + + if (data?.before?.status || data?.after?.status) { + events.push(convertStatus(data.before.status, data.after.status)); + } + + if (data?.before?.chapters || data?.after?.chapters) { + events.push(convertChapters(data.before.chapters, data.after.chapters)); + } + + if (data?.before?.volumes || data?.after?.volumes) { + events.push(convertVolumes(data.before.volumes, data.after.volumes)); + } + + if (data?.before?.score || data?.after?.score) { + events.push(convertScore(data.before.score, data.after.score)); + } + + if (data?.before?.rereads || data?.after?.rereads) { + events.push(convertRereads(data.before.rereads, data.after.rereads)); + } + + return events.filter((event) => event !== null); +}; diff --git a/utils/convert-activity/index.ts b/utils/convert-activity/index.ts index 7afa6ed8..28d00f2d 100644 --- a/utils/convert-activity/index.ts +++ b/utils/convert-activity/index.ts @@ -1,10 +1,14 @@ import { createFavoriteEvents } from './convert-favorite-activity'; import { createImportEvents } from './convert-import-activity'; +import { createReadEvents } from './convert-read-activity'; import { createWatchEvents } from './convert-watch-activity'; export const convertActivity = ( history: API.History< - API.HistoryWatchData | API.HistoryImportData | API.HistoryFavoriteData + | API.HistoryWatchData + | API.HistoryWatchImportData + | API.HistoryReadImportData + | API.HistoryFavoriteData >, ) => { switch (history.history_type) { @@ -14,10 +18,30 @@ export const convertActivity = ( history.history_type, history.data as API.HistoryWatchData, ); + case 'read_novel': + case 'read_novel_delete': + case 'read_manga': + case 'read_manga_delete': + return createReadEvents( + history.history_type, + history.data as API.HistoryReadData, + ); case 'watch_import': - return createImportEvents(history.data as API.HistoryImportData); + case 'read_import': + return createImportEvents( + history.history_type, + history.data as + | API.HistoryWatchImportData + | API.HistoryReadImportData, + ); case 'favourite_anime_add': case 'favourite_anime_remove': + case 'favourite_manga_add': + case 'favourite_manga_remove': + case 'favourite_novel_add': + case 'favourite_novel_remove': return createFavoriteEvents(history.history_type); + default: + return []; } }; diff --git a/utils/convert-notification.tsx b/utils/convert-notification.tsx index 9fb6f169..35a12d9a 100644 --- a/utils/convert-notification.tsx +++ b/utils/convert-notification.tsx @@ -104,8 +104,8 @@ const commentReply = ( ...getInitialData(notification), description: DESCRIPTIONS[notification.notification_type](username), href: getCommentLink(content_type, slug, base_comment_reference), - poster: ( - <ContentCard containerRatio={1} className="w-10" poster={avatar} /> + image: ( + <ContentCard containerRatio={1} className="w-10" image={avatar} /> ), }; }; @@ -126,8 +126,8 @@ const commentVote = ( ...getInitialData(notification), description: DESCRIPTIONS[notification.notification_type](username), href: getCommentLink(content_type, slug, base_comment_reference), - poster: ( - <ContentCard containerRatio={1} className="w-10" poster={avatar} /> + image: ( + <ContentCard containerRatio={1} className="w-10" image={avatar} /> ), }; }; @@ -142,8 +142,8 @@ const commentTag = ( ...getInitialData(notification), description: DESCRIPTIONS[notification.notification_type](username), href: getCommentLink(content_type, slug, base_comment_reference), - poster: ( - <ContentCard containerRatio={1} className="w-10" poster={avatar} /> + image: ( + <ContentCard containerRatio={1} className="w-10" image={avatar} /> ), }; }; @@ -158,8 +158,8 @@ const editComment = ( ...getInitialData(notification), description: DESCRIPTIONS[notification.notification_type](username), href: getCommentLink(content_type, slug, base_comment_reference), - poster: ( - <ContentCard containerRatio={1} className="w-10" poster={avatar} /> + image: ( + <ContentCard containerRatio={1} className="w-10" image={avatar} /> ), }; }; @@ -203,11 +203,11 @@ const scheduleAnime = ( notification.data.after.episodes_released, ), href: `/anime/${notification.data.slug}`, - poster: ( + image: ( <ContentCard containerRatio={1} className="w-10" - poster={notification.data.poster} + image={notification.data.image} /> ), }; @@ -222,8 +222,8 @@ const follow = ( ...getInitialData(notification), description: DESCRIPTIONS[notification.notification_type](username), href: `/u/${username}`, - poster: ( - <ContentCard containerRatio={1} className="w-10" poster={avatar} /> + image: ( + <ContentCard containerRatio={1} className="w-10" image={avatar} /> ), }; }; @@ -237,8 +237,8 @@ const collectionVote = ( ...getInitialData(notification), description: DESCRIPTIONS[notification.notification_type](username), href: `/collections/${slug}`, - poster: ( - <ContentCard containerRatio={1} className="w-10" poster={avatar} /> + image: ( + <ContentCard containerRatio={1} className="w-10" image={avatar} /> ), }; }; diff --git a/utils/edit-param-utils.ts b/utils/edit-param-utils.ts index e938092e..b688b91c 100644 --- a/utils/edit-param-utils.ts +++ b/utils/edit-param-utils.ts @@ -7,6 +7,10 @@ import { ANIME_EDIT_PARAMS, CHARACTER_EDIT_GROUPS, CHARACTER_EDIT_PARAMS, + MANGA_EDIT_GROUPS, + MANGA_EDIT_PARAMS, + NOVEL_EDIT_GROUPS, + NOVEL_EDIT_PARAMS, PERSON_EDIT_GROUPS, PERSON_EDIT_PARAMS, } from '@/utils/constants'; @@ -32,6 +36,12 @@ export const getEditParams = ( case 'anime': params = ANIME_EDIT_PARAMS; break; + case 'manga': + params = MANGA_EDIT_PARAMS; + break; + case 'novel': + params = NOVEL_EDIT_PARAMS; + break; case 'character': params = CHARACTER_EDIT_PARAMS; break; @@ -58,6 +68,10 @@ export const getEditGroups = (content_type: API.ContentType) => { switch (content_type) { case 'anime': return ANIME_EDIT_GROUPS; + case 'manga': + return MANGA_EDIT_GROUPS; + case 'novel': + return NOVEL_EDIT_GROUPS; case 'character': return CHARACTER_EDIT_GROUPS; case 'person': diff --git a/utils/generate-metadata.ts b/utils/generate-metadata.ts index d4a61639..42974460 100644 --- a/utils/generate-metadata.ts +++ b/utils/generate-metadata.ts @@ -8,11 +8,11 @@ export const DEFAULTS = { siteName: 'Hikka', images: '/preview.jpg', title: { - default: 'Hikka - енциклопедія аніме українською', + default: 'Hikka - енциклопедія аніме, манґи та ранобе українською', template: '%s / Hikka', }, description: - 'Hikka - українська онлайн енциклопедія аніме. Весь список аніме, детальна інформація до кожного тайтлу та зручний інтерфейс. Заповнюй власний список переглянутого, кастомізуй профіль та ділись з друзями.', + 'Hikka - українська онлайн енциклопедія аніме, манґи та ранобе. Весь список, манґи та ранобе, детальна інформація до кожного тайтлу та зручний інтерфейс. Заповнюй власний список переглянутого та прочитаного, кастомізуй профіль та ділись з друзями.', }; type OGImageDescriptor = { diff --git a/utils/title-adapter.ts b/utils/title-adapter.ts new file mode 100644 index 00000000..26f7b9bf --- /dev/null +++ b/utils/title-adapter.ts @@ -0,0 +1,58 @@ +type TitleData = { + title_en?: string; + title_ua?: string; + title_ja?: string; + title_original?: string; + name_ua?: string; + name_en?: string; + name_ja?: string; + name_native?: string; +}; + +type TitleLanguage = keyof TitleData; + +export const convertTitle = <TData>({ + data, + titleLanguage, +}: { + data: TData & TitleData; + titleLanguage: TitleLanguage; +}) => { + return { + ...data, + title: + data[titleLanguage] || + data[ + titleLanguage === 'title_ja' ? 'title_original' : 'title_ja' + ] || + data.title_ua || + data.title_en || + data.title_ja || + data.title_original || + data.name_ua || + data.name_en || + data.name_ja || + data.name_native || + '', + }; +}; + +export const convertTitleList = <TData>({ + data, + titleLanguage, +}: { + data: (TData & TitleData)[]; + titleLanguage: TitleLanguage; +}) => { + return data.map((entry) => convertTitle({ titleLanguage, data: entry })); +}; + +export const getTitle = <TData>({ + data, + titleLanguage, +}: { + data: TData & TitleData; + titleLanguage: TitleLanguage; +}) => { + return convertTitle({ data, titleLanguage }).title; +}; diff --git a/yarn.lock b/yarn.lock index ed5b3dc6..39edfe83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2586,10 +2586,10 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:14.2.3": - version: 14.2.3 - resolution: "@next/env@npm:14.2.3" - checksum: 10c0/25ab3ac2739c8e5ce35e1f50373238c5c428ab6b01d448ba78a6068dcdef88978b64f9a92790c324b2926ccc41390a67107154a0b0fee32fe980a485f4ef20d8 +"@next/env@npm:14.2.4": + version: 14.2.4 + resolution: "@next/env@npm:14.2.4" + checksum: 10c0/cc284e3dd0666df04d8321645d8409c10cb8e325884c226abbb2e7bea20f0a4232f988216aa506a9d0457b46f28b594a61179d1e978c0ca22497cd8cab8196c7 languageName: node linkType: hard @@ -2602,65 +2602,65 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:14.2.3": - version: 14.2.3 - resolution: "@next/swc-darwin-arm64@npm:14.2.3" +"@next/swc-darwin-arm64@npm:14.2.4": + version: 14.2.4 + resolution: "@next/swc-darwin-arm64@npm:14.2.4" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@next/swc-darwin-x64@npm:14.2.3": - version: 14.2.3 - resolution: "@next/swc-darwin-x64@npm:14.2.3" +"@next/swc-darwin-x64@npm:14.2.4": + version: 14.2.4 + resolution: "@next/swc-darwin-x64@npm:14.2.4" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:14.2.3": - version: 14.2.3 - resolution: "@next/swc-linux-arm64-gnu@npm:14.2.3" +"@next/swc-linux-arm64-gnu@npm:14.2.4": + version: 14.2.4 + resolution: "@next/swc-linux-arm64-gnu@npm:14.2.4" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:14.2.3": - version: 14.2.3 - resolution: "@next/swc-linux-arm64-musl@npm:14.2.3" +"@next/swc-linux-arm64-musl@npm:14.2.4": + version: 14.2.4 + resolution: "@next/swc-linux-arm64-musl@npm:14.2.4" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:14.2.3": - version: 14.2.3 - resolution: "@next/swc-linux-x64-gnu@npm:14.2.3" +"@next/swc-linux-x64-gnu@npm:14.2.4": + version: 14.2.4 + resolution: "@next/swc-linux-x64-gnu@npm:14.2.4" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:14.2.3": - version: 14.2.3 - resolution: "@next/swc-linux-x64-musl@npm:14.2.3" +"@next/swc-linux-x64-musl@npm:14.2.4": + version: 14.2.4 + resolution: "@next/swc-linux-x64-musl@npm:14.2.4" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:14.2.3": - version: 14.2.3 - resolution: "@next/swc-win32-arm64-msvc@npm:14.2.3" +"@next/swc-win32-arm64-msvc@npm:14.2.4": + version: 14.2.4 + resolution: "@next/swc-win32-arm64-msvc@npm:14.2.4" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@next/swc-win32-ia32-msvc@npm:14.2.3": - version: 14.2.3 - resolution: "@next/swc-win32-ia32-msvc@npm:14.2.3" +"@next/swc-win32-ia32-msvc@npm:14.2.4": + version: 14.2.4 + resolution: "@next/swc-win32-ia32-msvc@npm:14.2.4" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:14.2.3": - version: 14.2.3 - resolution: "@next/swc-win32-x64-msvc@npm:14.2.3" +"@next/swc-win32-x64-msvc@npm:14.2.4": + version: 14.2.4 + resolution: "@next/swc-win32-x64-msvc@npm:14.2.4" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -6340,6 +6340,23 @@ __metadata: languageName: node linkType: hard +"@udecode/plate-diff@npm:^34.0.0": + version: 34.0.0 + resolution: "@udecode/plate-diff@npm:34.0.0" + dependencies: + lodash: "npm:^4.17.21" + peerDependencies: + "@udecode/plate-common": ">=34.0.0" + react: ">=16.8.0" + react-dom: ">=16.8.0" + slate: ">=0.94.0" + slate-history: ">=0.93.0" + slate-hyperscript: ">=0.66.0" + slate-react: ">=0.99.0" + checksum: 10c0/571d210602a48d1d5ae42fc13d971efb00800fc2ba6b420dcbc84a92273cd96374f195b54c2fe3e2c9837ad6c78eb2855a6e38eefbb971431313a5636765e274 + languageName: node + linkType: hard + "@udecode/plate-floating@npm:34.0.1": version: 34.0.1 resolution: "@udecode/plate-floating@npm:34.0.1" @@ -11434,6 +11451,7 @@ __metadata: "@udecode/plate-basic-marks": "npm:^34.0.0" "@udecode/plate-break": "npm:^34.0.0" "@udecode/plate-common": "npm:^34.0.1" + "@udecode/plate-diff": "npm:^34.0.0" "@udecode/plate-link": "npm:^34.0.1" "@udecode/plate-list": "npm:^34.0.0" "@udecode/plate-paragraph": "npm:^34.0.0" @@ -11455,7 +11473,7 @@ __metadata: i18next: "npm:^23.11.2" lucide-react: "npm:^0.309.0" marked: "npm:^12.0.1" - next: "npm:14.2.3" + next: "npm:14.2.4" next-nprogress-bar: "npm:^2.3.9" next-plausible: "npm:^3.12.0" next-themes: "npm:^0.2.1" @@ -13929,20 +13947,20 @@ __metadata: languageName: node linkType: hard -"next@npm:14.2.3": - version: 14.2.3 - resolution: "next@npm:14.2.3" - dependencies: - "@next/env": "npm:14.2.3" - "@next/swc-darwin-arm64": "npm:14.2.3" - "@next/swc-darwin-x64": "npm:14.2.3" - "@next/swc-linux-arm64-gnu": "npm:14.2.3" - "@next/swc-linux-arm64-musl": "npm:14.2.3" - "@next/swc-linux-x64-gnu": "npm:14.2.3" - "@next/swc-linux-x64-musl": "npm:14.2.3" - "@next/swc-win32-arm64-msvc": "npm:14.2.3" - "@next/swc-win32-ia32-msvc": "npm:14.2.3" - "@next/swc-win32-x64-msvc": "npm:14.2.3" +"next@npm:14.2.4": + version: 14.2.4 + resolution: "next@npm:14.2.4" + dependencies: + "@next/env": "npm:14.2.4" + "@next/swc-darwin-arm64": "npm:14.2.4" + "@next/swc-darwin-x64": "npm:14.2.4" + "@next/swc-linux-arm64-gnu": "npm:14.2.4" + "@next/swc-linux-arm64-musl": "npm:14.2.4" + "@next/swc-linux-x64-gnu": "npm:14.2.4" + "@next/swc-linux-x64-musl": "npm:14.2.4" + "@next/swc-win32-arm64-msvc": "npm:14.2.4" + "@next/swc-win32-ia32-msvc": "npm:14.2.4" + "@next/swc-win32-x64-msvc": "npm:14.2.4" "@swc/helpers": "npm:0.5.5" busboy: "npm:1.6.0" caniuse-lite: "npm:^1.0.30001579" @@ -13983,7 +14001,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10c0/2c409154720846d07a7a995cc3bfba24b9ee73c87360ce3266528c8a217f5f1ab6f916cffbe1be83509b4e8d7b1d713921bb5c69338b4ecaa57df3212f79a8c5 + checksum: 10c0/630c2a197b57c1f29caf4672a0f8fb74dbb048e77e4513f567279467332212f3eebcb68279885f1d525d7aaebbb452f522b02c0b5cd3ca66f385341e4b4eac67 languageName: node linkType: hard