Compare commits

..

No commits in common. "a4ecc27874a85fd33036f76eb004cb13f9f2eb18" and "be0731cfbac0c75c13c69267f6f8a1e620440f26" have entirely different histories.

65 changed files with 673 additions and 3196 deletions

View file

@ -2,9 +2,8 @@
"$schema": "https://unpkg.com/flowbite-react/schema.json",
"components": [],
"dark": true,
"path": "src/components",
"prefix": "",
"rsc": true,
"path": "src/components",
"tsx": true,
"version": 3
"rsc": true
}

View file

@ -1,22 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore-all lint: auto-generated file
// This file is auto-generated by the flowbite-react CLI.
// Do not edit this file directly.
// Instead, edit the .flowbite-react/config.json file.
import { StoreInit } from "flowbite-react/store/init";
import React from "react";
export const CONFIG = {
dark: true,
prefix: "",
version: 3,
};
export function ThemeInit() {
return <StoreInit {...CONFIG} />;
}
ThemeInit.displayName = "ThemeInit";

View file

@ -226,9 +226,7 @@ app.get("/*path", async (req, res) => {
if (
!apiResponse ||
!apiResponse.ok ||
(apiResponse.headers.get("content-type") != "application/json" &&
apiResponse.headers.get("content-type") !=
"application/json;charset=UTF-8")
apiResponse.headers.get("content-type") != "application/json"
) {
logger.error(
`Failed to fetch: '${url.protocol}//${url.hostname}${url.pathname}', Path probably doesn't exist`
@ -279,7 +277,7 @@ app.post("/*path", async (req, res) => {
"application/json",
"application/x-www-form-urlencoded",
"multipart/form-data",
"x-unknown/unknown",
"x-unknown/unknown"
];
const isSupported = supportedContentTypes.includes(
@ -337,9 +335,7 @@ app.post("/*path", async (req, res) => {
if (
!apiResponse ||
!apiResponse.ok ||
(apiResponse.headers.get("content-type") != "application/json" &&
apiResponse.headers.get("content-type") !=
"application/json;charset=UTF-8")
apiResponse.headers.get("content-type") != "application/json"
) {
logger.error(
`Failed to post: '${url.protocol}//${url.hostname}${url.pathname}', Path probably doesn't exist`

View file

@ -1,6 +1,7 @@
"use client";
import { useUserStore } from "./store/auth";
import { usePreferencesStore } from "./store/preferences";
import { Navbar } from "./components/Navbar/NavbarUpdate";
import { Inter } from "next/font/google";
import { useEffect, useState } from "react";
import {
@ -13,9 +14,6 @@ import {
import { Spinner } from "./components/Spinner/Spinner";
import { ChangelogModal } from "#/components/ChangelogModal/ChangelogModal";
import { Bounce, ToastContainer } from "react-toastify";
import { NavBarPc } from "./components/Navbar/NavBarPc";
import { NavBarMobile } from "./components/Navbar/NavBarMobile";
import { SettingsModal } from "./components/SettingsModal/SettingsModal";
const inter = Inter({ subsets: ["latin"] });
@ -25,7 +23,6 @@ export const App = (props) => {
const [showChangelog, setShowChangelog] = useState(false);
const [currentVersion, setCurrentVersion] = useState("");
const [previousVersions, setPreviousVersions] = useState([]);
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
useEffect(() => {
async function _checkVersion() {
@ -71,8 +68,8 @@ export const App = (props) => {
<body
className={`${inter.className} overflow-x-hidden dark:bg-[#0d1117] dark:text-white`}
>
<NavBarPc setIsSettingModalOpen={setIsSettingModalOpen} />
<main className="container px-2 pt-4 pb-24 mx-auto lg:pb-0">
<Navbar />
<main className="container px-2 pt-4 pb-24 mx-auto sm:pb-0">
{props.children}
</main>
<ChangelogModal
@ -126,11 +123,6 @@ export const App = (props) => {
theme="colored"
transition={Bounce}
/>
<NavBarMobile setIsSettingModalOpen={setIsSettingModalOpen} />
<SettingsModal
isOpen={isSettingModalOpen}
setIsOpen={setIsSettingModalOpen}
/>
</body>
);
};

View file

@ -1,4 +1,4 @@
export const CURRENT_APP_VERSION = "3.9.0";
export const CURRENT_APP_VERSION = "3.8.0";
import { env } from "next-runtime-env";
const NEXT_PUBLIC_API_URL = env("NEXT_PUBLIC_API_URL") || null;
@ -51,7 +51,6 @@ export const ENDPOINTS = {
}
},
filter: `${API_PREFIX}/filter`,
filterTypes: `${API_PREFIX}/type/all`,
search: {
profileList: `${API_PREFIX}/search/profile/list`,
profileHistory: `${API_PREFIX}/search/history`,
@ -75,13 +74,5 @@ export const ENDPOINTS = {
releaseInCollections: `${API_PREFIX}/collection/all/release`,
userCollections: `${API_PREFIX}/collection/all/profile`,
favoriteCollections: `${API_PREFIX}/collectionFavorite`,
},
discover: {
interesting: `${API_PREFIX}/discover/interesting`,
discussing: `${API_PREFIX}/discover/discussing`,
watching: `${API_PREFIX}/discover/watching`,
recommendations: `${API_PREFIX}/discover/recommendations`,
collections: `${API_PREFIX}/collection/all`,
schedule: `${API_PREFIX}/schedule`,
}
};

View file

@ -248,519 +248,99 @@ export function sinceUnixDate(unixInSeconds: number) {
);
}
export function minutesToTime(min: number) {
const seconds = min * 60;
const epoch = new Date(0);
const date = new Date(seconds * 1000);
export function minutesToTime(
min: number,
type?: "full" | "daysOnly" | "daysHours"
) {
const d = Math.floor(min / 1440); // 60*24
const h = Math.floor((min - d * 1440) / 60);
const m = Math.round(min % 60);
const diffInMinutes =
new Date(date.getTime() - epoch.getTime()).getTime() / 1000 / 60;
var dDisplay =
d > 0 ? `${d} ${numberDeclension(d, "день", "дня", "дней")}` : "";
var hDisplay =
h > 0 ? `${h} ${numberDeclension(h, "час", "часа", "часов")}` : "";
var mDisplay =
m > 0 ? `${m} ${numberDeclension(m, "минута", "минуты", "минут")}` : "";
let days = Math.floor(diffInMinutes / 1440);
if (days < 0) days = 0;
const daysToMinutes = days * 1440;
let hours = Math.floor((diffInMinutes - daysToMinutes) / 60);
if (hours < 0) hours = 0;
const hoursToMinutes = hours * 60;
let minutes = diffInMinutes - daysToMinutes - hoursToMinutes;
if (minutes < 0) minutes = 0;
const dayDisplay =
days > 0 ? `${days} ${numberDeclension(days, "день", "дня", "дней")}` : "";
const hourDisplay =
hours > 0 ?
`${hours} ${numberDeclension(hours, "час", "часа", "часов")}`
: "";
const minuteDisplay =
minutes > 0 ?
`${minutes} ${numberDeclension(minutes, "минута", "минуты", "минут")}`
: "";
if (days > 0 && hours > 0 && minutes > 0)
return `${dayDisplay}, ${hourDisplay}, ${minuteDisplay}`;
if (days > 0 && hours > 0) return `${dayDisplay}, ${hourDisplay}`;
if (days > 0 && minutes > 0) return `${dayDisplay}, ${minuteDisplay}`;
if (hours > 0 && minutes > 0) return `${hourDisplay}, ${minuteDisplay}`;
if (days > 0) return dayDisplay;
if (hours > 0) return hourDisplay;
if (minutes > 0) return minuteDisplay;
if (type == "daysOnly") {
if (d > 0) return dDisplay;
return "? дней";
} else if (type == "daysHours") {
if (d > 0 && h > 0) return dDisplay + ", " + hDisplay;
if (h > 0) return hDisplay;
if (m > 0) return mDisplay;
} else {
return `${d > 0 ? dDisplay : ""}${h > 0 ? ", " + hDisplay : ""}${m > 0 ? ", " + mDisplay : ""}`;
}
}
export const FilterCountry = ["Япония", "Китай", "Южная Корея"];
export const FilterCategoryIdToString: Record<number, string> = {
1: "Сериал",
2: "Полнометражный фильм",
3: "OVA",
4: "Дорама",
const StatusList: Record<string, null | number> = {
last: null,
finished: 1,
ongoing: 2,
announce: 3,
};
export const FilterGenre = {
uncategorized: {
name: "Нет категории",
genres: [
"авангард",
"гурман",
"драма",
"комедия",
"повседневность",
"приключения",
"романтика",
"сверхъестественное",
"спорт",
"тайна",
"триллер",
"ужасы",
"фантастика",
"фэнтези",
"экшен",
"эротика",
"этти",
],
},
audience: {
name: "Аудитория",
genres: [
"детское",
"дзёсей",
"сэйнэн",
"сёдзё",
"сёдзё-ай",
"сёнен",
"сёнен-ай",
],
},
theme: {
name: "Тематика",
genres: [
"CGDCT",
"антропоморфизм",
"боевые искусства",
"вампиры",
"взрослые персонажи",
"видеоигры",
"военное",
"выживание",
"гарем",
"гонки",
"городское фэнтези",
"гэг-юмор",
"детектив",
"жестокость",
"забота о детях",
"злодейка",
"игра с высокими ставками",
"идолы (жен.)",
"идолы (муж.)",
"изобразительное искусство",
"исполнительское искусство",
"исторический",
"исэкай",
"иясикэй",
"командный спорт",
"космос",
"кроссдрессинг",
"культура отаку",
"любовный многоугольник",
"магическая смена пола",
"махо-сёдзё",
"медицина",
"меха",
"мифология",
"музыка",
"образовательное",
"организованная преступность",
"пародия",
"питомцы",
"психологическое",
"путешествие во времени",
"работа",
"реверс-гарем",
"реинкарнация",
"романтический подтекст",
"самураи",
"спортивные единоборства",
"стратегические игры",
"супер сила",
"удостоено наград",
"хулиганы",
"школа",
"шоу-бизнес",
],
},
};
export const FilterProfileListIdToString: Record<number, string> = {
0: "Избранное",
1: "Смотрю",
2: "В планах",
3: "Просмотрено",
4: "Отложено",
5: "Брошено",
};
export const FilterStudio = [
"A-1 Pictures",
"A.C.G.T",
"ACTAS, Inc",
"ACiD FiLM",
"AIC A.S.T.A",
"AIC PLUS",
"AIC Spirits",
"AIC",
"Animac",
"ANIMATE",
"Aniplex",
"ARMS",
"Artland",
"ARTMIC Studios",
"Asahi Production",
"Asia-Do",
"ASHI",
"Asread",
"Asmik Ace",
"Aubeck",
"BM Entertainment",
"Bandai Visua",
"Barnum Studio",
"Bee Train",
"BeSTACK",
"Blender Foundation",
"Bones",
"Brains Base",
"Bridge",
"Cinema Citrus",
"Chaos Project",
"Cherry Lips",
"David Production",
"Daume",
"Doumu",
"Dax International",
"DLE INC",
"Digital Frontier",
"Digital Works",
"Diomedea",
"DIRECTIONS Inc",
"Dogakobo",
"Dofus",
"Encourage Films",
"Feel",
"Fifth Avenue",
"Five Ways",
"Fuji TV",
"Foursome",
"GRAM Studio",
"G&G Entertainment",
"Gainax",
"GANSIS",
"Gathering",
"Gonzino",
"Gonzo",
"GoHands",
"Green Bunny",
"Group TAC",
"Hal Film Maker",
"Hasbro Studios",
"h.m.p",
"Himajin",
"Hoods Entertainment",
"Idea Factory",
"J.C.Staff",
"KANSAI",
"Kaname Production",
"Kitty Films",
"Knack",
"Kokusai Eigasha",
"KSS (студия)",
"Kyoto Animation",
"Lemon Heart",
"LMD",
"Madhouse Studios",
"Magic Bus",
"Manglobe Inc.",
"Manpuku Jinja",
"MAPPA",
"Milky",
"Minamimachi Bugyosho",
"Media Blasters",
"Mook Animation",
"Moonrock",
"MOVIC",
"Mushi Productions",
"Natural High",
"Nippon Animation",
"Nomad",
"Lerche",
"OB Planning",
"Office AO",
"Ordet",
"Oriental Light and Magic",
"OLM Inc.",
"P.A. Works",
"Palm Studio",
"Pastel",
"Phoenix Entertainment",
"Picture Magic",
"Pink",
"Pink Pineapple",
"Planet",
"Plum",
"PPM",
"Primastea",
"Production I.G",
"Project No.9",
"Radix",
"Rikuentai",
"Robot",
"Satelight",
"Seven",
"Seven Arcs",
"Shaft",
"Silver Link",
"Shinei Animation",
"Shogakukan Music & Digital Entertainment",
"Soft on Demand",
"Starchild Records",
"Studio 9 Maiami",
"Studio Tulip",
"Studio 4°C",
"Studio e.go!",
"Studio A.P.P.P",
"Studio Barcelona",
"Studio Blanc",
"Studio Comet",
"Studio Deen",
"Studio Fantasia",
"Studio Flag",
"Studio Gallop",
"Studio Ghibli",
"Studio Guts",
"Studio Gokumi",
"Studio Rikka",
"Studio Hibari",
"Studio Junio",
"Studio Khara",
"Studio Live",
"Studio Matrix",
"Studio Pierrot",
"Studio Egg",
"Sunrise",
"Synergy SP",
"Synergy Japan",
"Tatsunoko Production",
"Tele-Cartoon Japan",
"Telecom Animation Film",
"Tezuka Productions",
"The Answer Studio",
"TMS",
"TNK",
"Toei Animation",
"Tokyo Kids",
"TYO Animations",
"Transarts",
"Triangle Staff",
"Trinet Entertainment",
"Ufotable",
"Vega Entertainment",
"Victor Entertainment",
"Viewworks",
"White Fox",
"Wonder Farm",
"XEBEC-M2",
"Xebec",
"Yumeta Company",
"Zexcs",
"Zuiyo Eizo",
"8bit",
];
export const FilterSource = [
"Оригинал",
"Манга",
"Веб-манга",
"Енкома",
"Ранобэ",
"Новелла",
"Веб-новелла",
"Визуальная новелла",
"Игра",
"Карточная игра",
"Книга",
"Книга с картинками",
"Музыка",
"Радио",
"Более одного",
"Другое",
];
export const FilterYear = Array.from({ length: 200 }, (_, i) => 1900 + i); // 1900-2100 years around now
export const FilterSeasonIdToString = {
1: "Зима",
2: "Весна",
3: "Лето",
4: "Осень",
};
export const FilterEpisodeCount = [
{
name: "Неважно",
episodes_from: null,
episodes_to: null,
},
{
name: "От 1 до 12",
episodes_from: 1,
episodes_to: 12,
},
{
name: "От 13 до 25",
episodes_from: 13,
episodes_to: 25,
},
{
name: "От 26 до 100",
episodes_from: 26,
episodes_to: 100,
},
{
name: "Больше 100",
episodes_from: 100,
episodes_to: null,
},
];
export const FilterEpisodeDuration = [
{
name: "Неважно",
export async function _FetchHomePageReleases(
status: string,
token: string | null,
page: string | number = 0
) {
let statusId: null | number = null;
let categoryId: null | number = null;
if (status == "films") {
categoryId = 2;
} else {
statusId = StatusList[status];
}
const body = {
country: null,
season: null,
sort: 0,
studio: null,
age_ratings: [],
category_id: categoryId,
end_year: null,
episode_duration_from: null,
episode_duration_to: null,
},
{
name: "До 10 минут",
episode_duration_from: 1,
episode_duration_to: 10,
},
{
name: "До 30 минут",
episode_duration_from: 1,
episode_duration_to: 30,
},
{
name: "Более 30 минут",
episode_duration_from: 30,
episode_duration_to: null,
},
];
export const FilterStatusIdToString = {
1: "Вышел",
2: "Выходит",
3: "Анонс",
};
export const FilterAgeRatingToString = {
1: "0+",
2: "6+",
3: "12+",
4: "16+",
5: "18+",
};
export const FilterSortToString = {
0: "По дате добавления",
1: "По рейтингу",
2: "По годам",
3: "По популярности",
};
episodes_from: null,
episodes_to: null,
genres: [],
profile_list_exclusions: [],
start_year: null,
status_id: statusId,
types: [],
is_genres_exclude_mode_enabled: false,
};
export type Filter = {
country: null | string;
category_id: null | number;
genres: string[];
is_genres_exclude_mode_enabled: boolean;
profile_list_exclusions: number[];
types: number[]; // fetched from /type/all
studio: null | string;
source: null | string;
start_year: null | number;
end_year: null | number;
season: null | number;
episodes_from: null | number;
episodes_to: null | number;
episode_duration_from: null | number;
episode_duration_to: null | number;
status_id: null | number;
age_ratings: number[];
sort: number;
};
export const FilterDefault: Filter = {
country: null,
season: null,
sort: 0,
source: null,
studio: null,
age_ratings: [],
category_id: null,
end_year: null,
episode_duration_from: null,
episode_duration_to: null,
episodes_from: null,
episodes_to: null,
genres: [],
is_genres_exclude_mode_enabled: false,
profile_list_exclusions: [],
start_year: null,
status_id: null,
types: [],
};
export async function FetchFilter(
{
country,
category_id,
genres,
is_genres_exclude_mode_enabled,
profile_list_exclusions,
types,
studio,
source,
start_year,
end_year,
season,
episodes_from,
episodes_to,
episode_duration_from,
episode_duration_to,
status_id,
age_ratings,
sort,
}: Filter,
page: number,
token: null | string
) {
let url: string;
url = `${ENDPOINTS.filter}/${page}`;
if (token) {
url += `?token=${token}`;
}
const { data, error } = await fetchDataViaPost(
url,
JSON.stringify({
country,
category_id,
genres,
is_genres_exclude_mode_enabled,
profile_list_exclusions,
types,
studio,
source,
start_year,
end_year,
season,
episodes_from,
episodes_to,
episode_duration_from,
episode_duration_to,
status_id,
age_ratings,
sort,
const data: Object = fetch(url, {
method: "POST",
headers: HEADERS,
body: JSON.stringify(body),
})
.then((response) => {
if (response.ok) {
return response.json();
} else {
throw new Error("Error fetching data");
}
})
);
return [data, error];
.then((data: Object) => {
return data;
})
.catch((error) => {
console.log(error);
return null;
});
return data;
}
export const BookmarksList = {

View file

@ -6,12 +6,6 @@
display: none !important;
}
@media (hover: hover) and (min-width: 1024px) {
.swiper {
overflow: visible !important;
}
}
@media (hover: hover) {
.section:hover .swiper-button {
display: flex !important;

View file

@ -55,7 +55,7 @@ export const CollectionCourusel = (props: {
)}
</div>
<div className="m-4">
<div className={`swiper ${Styles.swiper}`}>
<div className="swiper">
<div className="swiper-wrapper">
{props.isMyCollections && (
<div className="swiper-slide" style={{ width: "fit-content" }}>

View file

@ -5,43 +5,60 @@ import Image from "next/image";
export const CollectionLink = (props: any) => {
return (
<Link href={`/collection/${props.id}`}>
<div className="relative w-full overflow-hidden rounded-lg group aspect-video">
<Image
src={props.image}
fill={true}
alt={""}
className="-z-[1] object-cover inset-0 absolute w-full h-full group-hover:scale-110 transition-all duration-300 ease-in-out"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black to-transparent"></div>
<div className="absolute flex flex-wrap items-start justify-start gap-0.5 sm:gap-1 left-2 top-2">
<Chip
icon_name="material-symbols--favorite"
name_2={props.favorites_count}
<div className="w-full aspect-video group">
<div
className="relative w-full h-full overflow-hidden bg-center bg-no-repeat bg-cover rounded-sm group-hover:animate-bg_zoom animate-bg_zoom_rev group-hover:[background-size:110%] "
style={{
backgroundImage: `linear-gradient(to bottom, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.9) 100%)`,
}}
>
<Image
src={props.image}
fill={true}
alt={props.title || ""}
className="-z-[1] object-cover"
sizes="
(max-width: 768px) 300px,
(max-width: 1024px) 600px,
900px
"
/>
{props.comment_count && (
<div className="absolute flex flex-wrap items-start justify-start gap-0.5 sm:gap-1 left-2 top-2">
<Chip
icon_name="material-symbols--comment"
name_2={props.comment_count}
icon_name="material-symbols--favorite"
name_2={props.favorites_count}
/>
)}
{props.is_private && (
<div className="flex items-center justify-center bg-yellow-400 rounded-sm">
<span className="w-3 px-4 py-2.5 text-white sm:px-4 sm:py-3 xl:px-6 xl:py-4 iconify mdi--lock"></span>
{props.comment_count && (
<Chip
icon_name="material-symbols--comment"
name_2={props.comment_count}
/>
)}
{props.is_private && (
<div className="flex items-center justify-center bg-yellow-400 rounded-sm">
<span className="w-3 px-4 py-2.5 text-white sm:px-4 sm:py-3 xl:px-6 xl:py-4 iconify mdi--lock"></span>
</div>
)}
{props.is_favorite && (
<div className="flex items-center justify-center bg-pink-500 rounded-sm">
<span className="w-3 px-4 py-2.5 text-white sm:px-4 sm:py-3 xl:px-6 xl:py-4 iconify mdi--heart"></span>
</div>
)}
</div>
<div className="absolute bottom-0 left-0 p-2 lg:translate-y-[100%] group-hover:lg:translate-y-0 transition-transform">
<div className="transition-transform lg:-translate-y-[calc(100%_+_1rem)] group-hover:lg:translate-y-0">
<p className="text-sm font-bold text-white md:text-base lg:text-lg xl:text-xl">
{props.title}
</p>
</div>
)}
{props.is_favorite && (
<div className="flex items-center justify-center bg-pink-500 rounded-sm">
<span className="w-3 px-4 py-2.5 text-white sm:px-4 sm:py-3 xl:px-6 xl:py-4 iconify mdi--heart"></span>
</div>
)}
</div>
<div className="absolute transition-transform bottom-2 left-2 lg:translate-y-full group-hover:lg:translate-y-0">
<p className="text-sm font-bold text-white transition-transform md:text-base lg:text-lg xl:text-xl line-clamp-2 lg:-translate-y-full group-hover:lg:translate-y-0">
{props.title}
</p>
<p className="mt-2 text-sm line-clamp-2 hidden sm:[display:-webkit-box]">
{props.description}
</p>
{props.description && (
<p className="text-xs font-light text-white md:text-sm lg:text-base xl:text-lg">
{`${props.description.slice(0, 125)}${
props.description.length > 125 ? "..." : ""
}`}
</p>
)}
</div>
</div>
</div>
</Link>

View file

@ -16,7 +16,7 @@ export const CollectionsSection = (props: {
</div>
)}
<div className="m-4">
<div className="grid justify-center grid-cols-2 gap-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid justify-center sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))] grid-cols-[100%] gap-2">
{props.isMyCollections && <AddCollectionLink />}
{props.content.map((collection) => {
return (
@ -25,6 +25,7 @@ export const CollectionsSection = (props: {
</div>
);
})}
{props.content.length == 1 && !props.isMyCollections && <div></div>}
</div>
</div>
</section>

View file

@ -1,31 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useSWRfetcher } from "#/api/utils";
import useSWR from "swr";
import { CollectionCourusel } from "../CollectionCourusel/CollectionCourusel";
import { useUserStore } from "#/store/auth";
export const CollectionsOfTheWeek = () => {
const token = useUserStore((state) => state.token);
const { data, isLoading, error } = useSWR(
`${ENDPOINTS.discover.collections}/-1?previous_page=0&where=2&sort=4${token ? `&token=${token}` : ""}`,
useSWRfetcher,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
);
if (error) return <></>;
if (isLoading) return <></>;
return (
<CollectionCourusel
sectionTitle="Коллекции недели"
showAllLink={`/discovery/collections?sort=week_popular`}
content={data.content}
/>
);
};

View file

@ -1,50 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useSWRfetcher } from "#/api/utils";
import { useUserStore } from "#/store/auth";
import Link from "next/link";
import { PosterWithStuff } from "../ReleasePoster/PosterWithStuff";
import useSWR from "swr";
export const DiscussingToday = () => {
const token = useUserStore((state) => state.token);
const { data, isLoading, error } = useSWR(
`${ENDPOINTS.discover.discussing}${token ? `?token=${token}` : ""}`,
useSWRfetcher,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
);
if (error) return <></>;
if (isLoading) return <></>;
return (
<div>
<div className="flex justify-between px-4 py-2 border-b-2 border-black dark:border-white">
<h1 className="font-bold text-md sm:text-xl md:text-lg xl:text-xl">
Обсуждаемое сегодня
</h1>
</div>
<div className="flex gap-2 my-4 overflow-auto">
{data.content.map((item) => {
return (
<Link
key={`discover-discussing-${item.id}`}
href={`/release/${item.id}`}
className="min-w-[256px]"
>
<PosterWithStuff
settings={{ showDescription: false, showGenres: false }}
{...item}
/>
</Link>
);
})}
</div>
</div>
);
};

View file

@ -1,21 +0,0 @@
.swiper-button:global(.swiper-button-disabled) {
opacity: 0 !important;
}
.swiper-button {
display: none !important;
}
@media (hover: hover) and (min-width: 1024px) {
.swiper {
overflow: visible !important;
}
}
@media (hover: hover) {
.swiper:hover .swiper-button {
display: flex !important;
width: 64px;
height: 64px;
}
}

View file

@ -1,78 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useSWRfetcher } from "#/api/utils";
import useSWR from "swr";
import Image from "next/image";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/navigation";
import { Navigation } from "swiper/modules";
import Styles from "./InterestingCarousel.module.css";
import Link from "next/link";
export const InterestingCarousel = () => {
const { data, isLoading, error } = useSWR(
ENDPOINTS.discover.interesting,
useSWRfetcher,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
);
if (error) return <></>;
if (isLoading) return <></>;
return (
<div>
<Swiper
modules={[Navigation]}
spaceBetween={8}
slidesPerView={"auto"}
direction={"horizontal"}
rewind={true}
navigation={{
nextEl: ".swiper-button-next",
prevEl: ".swiper-button-prev",
}}
allowTouchMove={true}
className={Styles.swiper}
>
{data.content.map((item) => {
return (
<SwiperSlide
key={`discover-interesting-${item.id}`}
style={{ maxWidth: "fit-content" }}
>
<Link href={`/release/${item.action}`}>
<div className="relative w-[300px] md:w-[480px] aspect-video rounded-lg overflow-hidden">
<Image
src={item.image}
alt=""
fill={true}
className="absolute inset-0 object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black to-transparent"></div>
<div className="absolute bottom-2 left-2 right-2">
<p className="text-xl font-bold">{item.title}</p>
<p>{item.description}</p>
</div>
</div>
</Link>
</SwiperSlide>
);
})}
<div
className={`swiper-button-prev ${Styles["swiper-button"]} after:iconify after:material-symbols--chevron-left aspect-square bg-black bg-opacity-25 backdrop-blur rounded-full after:bg-white`}
style={{ "--swiper-navigation-size": "64px" } as React.CSSProperties}
></div>
<div
className={`swiper-button-next ${Styles["swiper-button"]} after:iconify after:material-symbols--chevron-right aspect-square bg-black bg-opacity-25 backdrop-blur rounded-full after:bg-white`}
style={{ "--swiper-navigation-size": "64px" } as React.CSSProperties}
></div>
</Swiper>
</div>
);
};

View file

@ -1,106 +0,0 @@
"use client";
import { FilterAgeRatingToString } from "#/api/utils";
import {
Button,
Checkbox,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from "flowbite-react";
import { useEffect, useState } from "react";
type Props = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
ageRatings: number[];
setAgeRatings: (lists: number[]) => void;
};
export const FiltersAgeRatingModal = ({
isOpen,
setIsOpen,
ageRatings,
setAgeRatings,
}: Props) => {
const [newAgeRatings, setNewAgeRatings] = useState(ageRatings);
function toggleRating(number: number) {
if (newAgeRatings.includes(number)) {
setNewAgeRatings(newAgeRatings.filter((rating) => rating != number));
} else {
setNewAgeRatings([...newAgeRatings, number]);
}
}
useEffect(() => {
setNewAgeRatings(ageRatings);
}, [ageRatings]);
return (
<Modal show={isOpen} onClose={() => setIsOpen(false)} dismissible>
<ModalHeader>Выберите списки</ModalHeader>
<ModalBody>
{Object.entries(FilterAgeRatingToString).map(([key, value]) => {
return (
<div
className="flex items-center gap-2"
key={`filter-age-rating-${value}`}
>
<Checkbox
id={`filter-age-rating-${value}`}
onChange={() => toggleRating(Number(key))}
checked={newAgeRatings.includes(Number(key))}
color="blue"
/>
<Label htmlFor={`filter-age-rating-${value}`}>{value}</Label>
</div>
);
})}
</ModalBody>
<ModalFooter>
<Button
onClick={() => {
setAgeRatings([]);
setNewAgeRatings([]);
setIsOpen(false);
}}
color="red"
>
Сбросить
</Button>
<Button
onClick={() => {
setAgeRatings(newAgeRatings);
setNewAgeRatings([]);
setIsOpen(false);
}}
color="blue"
>
Применить
</Button>
<Button
onClick={() => {
if (
newAgeRatings.length !=
Object.keys(FilterAgeRatingToString).length
) {
setNewAgeRatings(
Object.keys(FilterAgeRatingToString).map((key) => Number(key))
);
} else {
setNewAgeRatings([]);
}
}}
color="light"
>
{newAgeRatings.length >= Object.keys(FilterAgeRatingToString).length ?
"Снять все"
: "Выбрать все"}
</Button>
</ModalFooter>
</Modal>
);
};

View file

@ -1,141 +0,0 @@
"use client";
import { FilterGenre } from "#/api/utils";
import {
Button,
Checkbox,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
ToggleSwitch,
} from "flowbite-react";
import { useEffect, useState } from "react";
type Props = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
genres: string[];
exclusionMode: boolean;
save: (genres, exclusionMode) => void;
};
export const FiltersGenreModal = ({
isOpen,
setIsOpen,
genres,
exclusionMode,
save,
}: Props) => {
const [newGenres, setNewGenres] = useState(genres);
const [newExclusionMode, setNewExclusionMode] = useState(exclusionMode);
const genresLength =
FilterGenre.uncategorized.genres.length +
FilterGenre.audience.genres.length +
FilterGenre.theme.genres.length;
function toggleGenre(string: string) {
if (newGenres.includes(string)) {
setNewGenres(newGenres.filter((genre) => genre != string));
} else {
setNewGenres([...newGenres, string]);
}
}
useEffect(() => {
setNewGenres(genres);
setNewExclusionMode(exclusionMode);
}, [genres, exclusionMode]);
return (
<Modal show={isOpen} onClose={() => setIsOpen(false)} dismissible size="6xl">
<ModalHeader>Жанры</ModalHeader>
<ModalBody>
<div>
{Object.entries(FilterGenre).map(([key, value]) => {
return (
<div key={`filter-genre-category-${value.name}`} className="mb-4">
<p className="mb-2">{value.name}</p>
{value.genres.map((genre) => {
return (
<div
className="flex items-center gap-2"
key={`filter-genre-category-${value.name}-${genre}`}
>
<Checkbox
id={`filter-genre-category-${value.name}-genre-${genre}`}
onChange={() => toggleGenre(genre)}
checked={newGenres.includes(genre)}
color="blue"
/>
<Label
htmlFor={`filter-genre-category-${value.name}-genre-${genre}`}
>
{genre}
</Label>
</div>
);
})}
</div>
);
})}
</div>
</ModalBody>
<ModalFooter>
<div className="flex justify-between w-full">
<div className="flex items-center gap-2">
<div>
<p className="mb-1 font-bold">Режим исключения</p>
<p className="text-sm text-gray-400 dark:text-gray-300 max-w-52">
Фильтр будет искать релизы не содержащие ни один из указанных
выше жанров
</p>
</div>
<ToggleSwitch
color="blue"
onChange={() => setNewExclusionMode(!newExclusionMode)}
checked={newExclusionMode}
/>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => {
save([], false);
setNewGenres([]);
setNewExclusionMode(false);
setIsOpen(false);
}}
color="red"
>
Сбросить
</Button>
<Button
onClick={() => {
save(newGenres, newExclusionMode);
setNewGenres([]);
setNewExclusionMode(false);
setIsOpen(false);
}}
color="blue"
>
Применить
</Button>
<Button
onClick={() => {
if (newGenres.length != genresLength) {
setNewGenres(Object.entries(FilterGenre).map(([key, value]) => value.genres.map((genre) => genre)).flat());
} else {
setNewGenres([]);
}
}}
color="light"
>
{newGenres.length >= genresLength ? "Снять все" : "Выбрать все"}
</Button>
</div>
</div>
</ModalFooter>
</Modal>
);
};

View file

@ -1,107 +0,0 @@
"use client";
import { FilterProfileListIdToString } from "#/api/utils";
import {
Button,
Checkbox,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from "flowbite-react";
import { useEffect, useState } from "react";
type Props = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
lists: number[];
setLists: (lists: number[]) => void;
};
export const FiltersListExcludeModal = ({
isOpen,
setIsOpen,
lists,
setLists,
}: Props) => {
const [newList, setNewList] = useState(lists);
function toggleList(number: number) {
if (newList.includes(number)) {
setNewList(newList.filter((list) => list != number));
} else {
setNewList([...newList, number]);
}
}
useEffect(() => {
setNewList(lists);
}, [lists]);
return (
<Modal show={isOpen} onClose={() => setIsOpen(false)} dismissible>
<ModalHeader>Выберите списки</ModalHeader>
<ModalBody>
{Object.entries(FilterProfileListIdToString).map(([key, value]) => {
return (
<div
className="flex items-center gap-2"
key={`filter-list-exclude-${value}`}
>
<Checkbox
id={`filter-list-exclude-${value}`}
onChange={() => toggleList(Number(key))}
checked={newList.includes(Number(key))}
color="blue"
/>
<Label htmlFor={`filter-list-exclude-${value}`}>{value}</Label>
</div>
);
})}
</ModalBody>
<ModalFooter>
<Button
onClick={() => {
setLists([]);
setNewList([]);
setIsOpen(false);
}}
color="red"
>
Сбросить
</Button>
<Button
onClick={() => {
setLists(newList);
setNewList([]);
setIsOpen(false);
}}
color="blue"
>
Применить
</Button>
<Button
onClick={() => {
if (
newList.length != Object.keys(FilterProfileListIdToString).length
) {
setNewList(
Object.keys(FilterProfileListIdToString).map((key) =>
Number(key)
)
);
} else {
setNewList([]);
}
}}
color="light"
>
{newList.length >= Object.keys(FilterProfileListIdToString).length ?
"Снять все"
: "Выбрать все"}
</Button>
</ModalFooter>
</Modal>
);
};

View file

@ -1,565 +0,0 @@
"use client";
import {
Filter,
FilterAgeRatingToString,
FilterCategoryIdToString,
FilterCountry,
FilterDefault,
FilterEpisodeCount,
FilterEpisodeDuration,
FilterProfileListIdToString,
FilterSeasonIdToString,
FilterSortToString,
FilterSource,
FilterStatusIdToString,
FilterStudio,
FilterYear,
tryCatchAPI,
} from "#/api/utils";
import {
Button,
Dropdown,
DropdownItem,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from "flowbite-react";
import { useEffect, useState } from "react";
import { FiltersGenreModal } from "./FiltersGenreModal";
import { useUserStore } from "#/store/auth";
import { FiltersListExcludeModal } from "./FiltersListExcludeModal";
import { ENDPOINTS } from "#/api/config";
import { FiltersTypesModal } from "./FiltersTypesModal";
import { FiltersAgeRatingModal } from "./FiltersAgeRatingModal";
import { useRouter } from "next/navigation";
type ModalProps = {
isOpen: boolean;
setIsOpen: (value: boolean) => void;
filter?: Filter;
setFilter?: (filter: Filter) => void;
};
export const FiltersModal = ({
isOpen,
setIsOpen,
filter,
setFilter,
}: ModalProps) => {
const userStore = useUserStore();
const router = useRouter();
const [newFilter, setNewFilter] = useState(filter || FilterDefault);
const [isGenreModalOpen, setIsGenreModalOpen] = useState(false);
const [isListExcludeModalOpen, setIsListExcludeModalOpen] = useState(false);
const [isTypeModalOpen, setIsTypeModalOpen] = useState(false);
const [isAgeRatingModalOpen, setIsAgeRatingModalOpen] = useState(false);
const [types, setTypes] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
setError(null);
const { data, error } = await tryCatchAPI(fetch(ENDPOINTS.filterTypes));
if (error) {
setError(error);
} else {
setTypes(data.types);
}
};
fetchData();
}, []);
function saveGenres(genres, is_genres_exclude_mode_enabled) {
setNewFilter({ ...newFilter, genres, is_genres_exclude_mode_enabled });
}
function saveFilter() {
const _filter = JSON.stringify(newFilter);
if (setFilter) {
setFilter(newFilter);
} else {
router.push(`/discovery/filter?filter=${_filter}`);
}
setIsOpen(false);
}
return (
<>
<Modal
show={isOpen}
onClose={() => setIsOpen(false)}
size="4xl"
dismissible
>
<ModalHeader>Фильтр</ModalHeader>
<ModalBody>
<div className="space-y-4">
<div className="space-y-2">
<p>Страна</p>
<Dropdown
label={newFilter.country || "Неважно"}
color="blue"
className="w-full"
>
<DropdownItem
key={`filter-modal-country-none`}
onClick={() => setNewFilter({ ...newFilter, country: null })}
>
Неважно
</DropdownItem>
{FilterCountry.map((item) => {
return (
<DropdownItem
key={`filter-modal-country-${item}`}
onClick={() =>
setNewFilter({ ...newFilter, country: item })
}
>
{item}
</DropdownItem>
);
})}
</Dropdown>
</div>
<div className="space-y-2">
<p>Категория</p>
<Dropdown
label={
newFilter.category_id ?
FilterCategoryIdToString[newFilter.category_id]
: "Неважно"
}
color="blue"
className="w-full"
>
<DropdownItem
key={`filter-modal-category-none`}
onClick={() =>
setNewFilter({ ...newFilter, category_id: null })
}
>
Неважно
</DropdownItem>
{Object.entries(FilterCategoryIdToString).map(
([key, value]) => {
return (
<DropdownItem
key={`filter-modal-category-${key}`}
onClick={() =>
setNewFilter({
...newFilter,
category_id: Number(key),
})
}
>
{value}
</DropdownItem>
);
}
)}
</Dropdown>
</div>
<div className="space-y-2">
<p>Жанры</p>
<Button
color={"blue"}
className="w-full min-h-10 h-fit"
onClick={() => setIsGenreModalOpen(true)}
>
{newFilter.genres.length > 0 ?
newFilter.genres.join(", ")
: "Неважно"}
</Button>
<p className="text-sm">
Будет искать релизы, содержащие каждый из указанных жанров.
Рекомендуется выбирать не более 3 жанров
</p>
</div>
{userStore.isAuth ?
<div className="space-y-2">
<p>Исключить закладки</p>
<Button
color={"blue"}
className="w-full min-h-10 h-fit"
onClick={() => setIsListExcludeModalOpen(true)}
>
{newFilter.profile_list_exclusions.length > 0 ?
newFilter.profile_list_exclusions
.map((id) => FilterProfileListIdToString[id])
.join(", ")
: "Неважно"}
</Button>
<p className="text-sm">
Исключит из выдачи релизы, входящие в указанные закладки
</p>
</div>
: ""}
<div className="space-y-2">
<p>Варианты озвучек</p>
<Button
color={"blue"}
className="w-full min-h-10 h-fit"
onClick={() => setIsTypeModalOpen(true)}
>
{error ?
error.message
: newFilter.types.length > 0 ?
newFilter.types
.map((type) => types.find((t) => t.id === type).name)
.join(", ")
: "Неважно"}
</Button>
</div>
<div className="space-y-2">
<p>Студия</p>
<Dropdown
label={newFilter.studio ? newFilter.studio : "Неважно"}
color="blue"
className="w-full overflow-y-auto max-h-64"
>
<DropdownItem
key={`filter-modal-studio-none`}
onClick={() => setNewFilter({ ...newFilter, studio: null })}
>
Неважно
</DropdownItem>
{FilterStudio.map((value) => {
return (
<DropdownItem
key={`filter-modal-studio-${value}`}
onClick={() =>
setNewFilter({
...newFilter,
studio: value,
})
}
>
{value}
</DropdownItem>
);
})}
</Dropdown>
</div>
<div className="space-y-2">
<p>Первоисточник</p>
<Dropdown
label={newFilter.source ? newFilter.source : "Неважно"}
color="blue"
className="w-full overflow-y-auto max-h-64"
>
<DropdownItem
key={`filter-modal-source-none`}
onClick={() => setNewFilter({ ...newFilter, source: null })}
>
Неважно
</DropdownItem>
{FilterSource.map((value) => {
return (
<DropdownItem
key={`filter-modal-source-${value}`}
onClick={() =>
setNewFilter({
...newFilter,
source: value,
})
}
>
{value}
</DropdownItem>
);
})}
</Dropdown>
</div>
<div className="space-y-2">
<p>Года</p>
<div className="flex items-center gap-2">
<p>С</p>
<Dropdown
label={
newFilter.start_year ? newFilter.start_year : "Неважно"
}
color="blue"
className="mr-2 overflow-y-auto max-h-64"
>
<DropdownItem
key={`filter-modal-year-start-none`}
onClick={() =>
setNewFilter({ ...newFilter, start_year: null })
}
>
Неважно
</DropdownItem>
{FilterYear.map((value) => {
return (
<DropdownItem
key={`filter-modal-year-start-${value}`}
onClick={() =>
setNewFilter({
...newFilter,
start_year: value,
})
}
>
{value}
</DropdownItem>
);
})}
</Dropdown>
<p>По</p>
<Dropdown
label={newFilter.end_year ? newFilter.end_year : "Неважно"}
color="blue"
className="mr-2 overflow-y-auto max-h-64"
>
<DropdownItem
key={`filter-modal-year-end-none`}
onClick={() =>
setNewFilter({ ...newFilter, end_year: null })
}
>
Неважно
</DropdownItem>
{FilterYear.map((value) => {
return (
<DropdownItem
key={`filter-modal-year-end-${value}`}
onClick={() =>
setNewFilter({
...newFilter,
end_year: value,
})
}
>
{value}
</DropdownItem>
);
})}
</Dropdown>
<p>Сезон</p>
<Dropdown
label={
newFilter.season ?
FilterSeasonIdToString[newFilter.season]
: "Неважно"
}
color="blue"
className="mr-2 overflow-y-auto max-h-64"
>
<DropdownItem
key={`filter-modal-year-end-none`}
onClick={() => setNewFilter({ ...newFilter, season: null })}
>
Неважно
</DropdownItem>
{Object.entries(FilterSeasonIdToString).map(
([key, value]) => {
return (
<DropdownItem
key={`filter-modal-season-${value}`}
onClick={() =>
setNewFilter({
...newFilter,
season: Number(key),
})
}
>
{value}
</DropdownItem>
);
}
)}
</Dropdown>
</div>
</div>
<div className="space-y-2">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<p>Эпизодов</p>
<Dropdown
label={
FilterEpisodeCount.find(
(episode) =>
episode.episodes_from === newFilter.episodes_from &&
episode.episodes_to === newFilter.episodes_to
).name
}
color="blue"
className="w-full overflow-y-auto max-h-64"
>
{FilterEpisodeCount.map((value) => {
return (
<DropdownItem
key={`filter-modal-episode-count-${value.name}`}
onClick={() =>
setNewFilter({
...newFilter,
episodes_from: value.episodes_from,
episodes_to: value.episodes_to,
})
}
>
{value.name}
</DropdownItem>
);
})}
</Dropdown>
</div>
<div className="space-y-2">
<p>Длительность эпизода</p>
<Dropdown
label={
FilterEpisodeDuration.find(
(episode) =>
episode.episode_duration_from ===
newFilter.episode_duration_from &&
episode.episode_duration_to ===
newFilter.episode_duration_to
).name
}
color="blue"
className="w-full overflow-y-auto max-h-64"
>
{FilterEpisodeDuration.map((value) => {
return (
<DropdownItem
key={`filter-modal-episode-duration-${value.name}`}
onClick={() =>
setNewFilter({
...newFilter,
episode_duration_from:
value.episode_duration_from,
episode_duration_to: value.episode_duration_to,
})
}
>
{value.name}
</DropdownItem>
);
})}
</Dropdown>
</div>
<div className="space-y-2">
<p>Статус</p>
<Dropdown
label={
newFilter.status_id ?
FilterStatusIdToString[newFilter.status_id]
: "Неважно"
}
color="blue"
className="w-full overflow-y-auto max-h-64"
>
<DropdownItem
key={`filter-modal-status-none`}
onClick={() =>
setNewFilter({ ...newFilter, status_id: null })
}
>
Неважно
</DropdownItem>
{Object.entries(FilterStatusIdToString).map(
([key, value]) => {
return (
<DropdownItem
key={`filter-modal-status-${value}`}
onClick={() =>
setNewFilter({
...newFilter,
status_id: Number(key),
})
}
>
{value}
</DropdownItem>
);
}
)}
</Dropdown>
</div>
</div>
</div>
<div className="space-y-2">
<p>Возрастное ограничение</p>
<Button
color={"blue"}
className="w-full min-h-10 h-fit"
onClick={() => setIsAgeRatingModalOpen(true)}
>
{newFilter.age_ratings.length > 0 ?
newFilter.age_ratings
.map((age_rating) => FilterAgeRatingToString[age_rating])
.join(", ")
: "Неважно"}
</Button>
</div>
<div className="space-y-2">
<p>Сортировка</p>
<Dropdown
label={FilterSortToString[newFilter.sort]}
color="blue"
className="w-full overflow-y-auto max-h-64"
>
{Object.entries(FilterSortToString).map(([key, value]) => {
return (
<DropdownItem
key={`filter-modal-sort-${value}`}
onClick={() =>
setNewFilter({
...newFilter,
sort: Number(key),
})
}
>
{value}
</DropdownItem>
);
})}
</Dropdown>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="blue" onClick={saveFilter}>
Применить
</Button>
</ModalFooter>
</Modal>
<FiltersGenreModal
isOpen={isGenreModalOpen}
setIsOpen={setIsGenreModalOpen}
genres={newFilter.genres}
exclusionMode={newFilter.is_genres_exclude_mode_enabled}
save={saveGenres}
/>
<FiltersListExcludeModal
isOpen={isListExcludeModalOpen}
setIsOpen={setIsListExcludeModalOpen}
lists={newFilter.profile_list_exclusions}
setLists={(profile_list_exclusions) =>
setNewFilter({ ...newFilter, profile_list_exclusions })
}
/>
<FiltersTypesModal
isOpen={isTypeModalOpen}
setIsOpen={setIsTypeModalOpen}
typesData={types}
types={newFilter.types}
setTypes={(types) => setNewFilter({ ...newFilter, types })}
/>
<FiltersAgeRatingModal
isOpen={isAgeRatingModalOpen}
setIsOpen={setIsAgeRatingModalOpen}
ageRatings={newFilter.age_ratings}
setAgeRatings={(age_ratings) =>
setNewFilter({ ...newFilter, age_ratings })
}
/>
</>
);
};

View file

@ -1,100 +0,0 @@
"use client";
import {
Button,
Checkbox,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from "flowbite-react";
import { useEffect, useState } from "react";
type Props = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
typesData: any[];
types: number[];
setTypes: (types: number[]) => void;
};
export const FiltersTypesModal = ({
isOpen,
setIsOpen,
typesData,
types,
setTypes,
}: Props) => {
const [newTypes, setNewTypes] = useState(types);
function toggleType(number: number) {
if (newTypes.includes(number)) {
setNewTypes(newTypes.filter((list) => list != number));
} else {
setNewTypes([...newTypes, number]);
}
}
useEffect(() => {
setNewTypes(types);
}, [types]);
return (
<Modal show={isOpen} onClose={() => setIsOpen(false)} dismissible>
<ModalHeader>Выберите списки</ModalHeader>
<ModalBody>
{typesData.map((item) => {
return (
<div
className="flex items-center gap-2"
key={`filter-types-${item.name}`}
>
<Checkbox
id={`filter-types-${item.name}`}
onChange={() => toggleType(Number(item.id))}
checked={newTypes.includes(Number(item.id))}
color="blue"
/>
<Label htmlFor={`filter-types-${item.name}`}>{item.name}</Label>
</div>
);
})}
</ModalBody>
<ModalFooter>
<Button
onClick={() => {
setTypes([]);
setNewTypes([]);
setIsOpen(false);
}}
color="red"
>
Сбросить
</Button>
<Button
onClick={() => {
setTypes(newTypes);
setNewTypes([]);
setIsOpen(false);
}}
color="blue"
>
Применить
</Button>
<Button
onClick={() => {
if (newTypes.length != typesData.length) {
setNewTypes(typesData.map((item) => Number(item.id)));
} else {
setNewTypes([]);
}
}}
color="light"
>
{newTypes.length >= typesData.length ? "Снять все" : "Выбрать все"}
</Button>
</ModalFooter>
</Modal>
);
};

View file

@ -1,34 +0,0 @@
import { FilterDefault } from "#/api/utils";
export const TabOngoing = {
id: "ongoing",
name: "Онгоинги",
filter: {
...FilterDefault,
sort: 3,
episodes_from: 1,
episodes_to: 48,
status_id: 2,
},
};
export const TabFinished = {
id: "finished",
name: "Завершённые",
filter: { ...FilterDefault, sort: 3, status_id: 1 },
};
export const TabFilms = {
id: "films",
name: "Фильмы",
filter: { ...FilterDefault, sort: 3, category_id: 2 },
};
export const TabOVA = {
id: "ova",
name: "OVA",
filter: { ...FilterDefault, sort: 3, category_id: 3 },
};
export const tabs = [TabOngoing, TabFinished, TabFilms, TabOVA];
export const tabsId = { ongoing: 0, finished: 1, films: 2, ova: 3 };

View file

@ -1,82 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { FetchFilter, useSWRfetcher } from "#/api/utils";
import { useUserStore } from "#/store/auth";
import useSWR from "swr";
import { tabs, tabsId } from "./PopularFilters";
import {
Button,
ButtonGroup,
Modal,
ModalBody,
ModalHeader,
} from "flowbite-react";
import { useEffect, useState } from "react";
import { ReleaseLink } from "#/components/ReleaseLink/ReleaseLinkUpdate";
import { Spinner } from "#/components/Spinner/Spinner";
type ModalProps = {
isOpen: boolean;
setIsOpen: (value: boolean) => void;
};
export const PopularModal = ({ isOpen, setIsOpen }: ModalProps) => {
const token = useUserStore((state) => state.token);
const [tab, setTab] = useState<
"ongoing" | "finished" | "films" | "ova" | string
>("ongoing");
const [content, setContent] = useState(null);
useEffect(() => {
setContent(null);
async function _loadReleases() {
const [data, error] = await FetchFilter(
tabs[tabsId[tab]].filter,
0,
token
);
if (!error) {
setContent(data.content);
}
}
_loadReleases();
}, [tab, token]);
return (
<Modal
show={isOpen}
onClose={() => setIsOpen(false)}
size="7xl"
dismissible
>
<ModalHeader>Популярное</ModalHeader>
<ModalBody>
<div className="flex items-center justify-between w-full gap-2">
<ButtonGroup>
{tabs.map((item) => (
<Button
key={`tabs-popular-${item.name}`}
color={tab === item.id ? "blue" : "light"}
onClick={() => setTab(item.id)}
>
{item.name}
</Button>
))}
</ButtonGroup>
</div>
<div className="grid grid-cols-2 gap-2 mt-4 lg:grid-cols-3 xl:grid-cols-4">
{content ?
content.map((release) => {
return (
<div key={release.id} className="w-full h-full">
<ReleaseLink {...release} />
</div>
);
})
: <Spinner />}
</div>
</ModalBody>
</Modal>
);
};

View file

@ -1,71 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useSWRfetcher } from "#/api/utils";
import useSWR from "swr";
import { Modal, ModalBody, ModalHeader } from "flowbite-react";
import { Spinner } from "#/components/Spinner/Spinner";
import { ReleaseCourusel } from "#/components/ReleaseCourusel/ReleaseCourusel";
type ModalProps = {
isOpen: boolean;
setIsOpen: (value: boolean) => void;
};
export const ScheduleModal = ({ isOpen, setIsOpen }: ModalProps) => {
const { data, isLoading, error } = useSWR(
ENDPOINTS.discover.schedule,
useSWRfetcher,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
);
return (
<Modal
show={isOpen}
onClose={() => setIsOpen(false)}
size="7xl"
dismissible
>
<ModalHeader>Расписание</ModalHeader>
<ModalBody>
{!error ?
isLoading ?
<div className="flex items-center justify-center w-full h-64">
<Spinner />
</div>
: <div className="flex flex-col gap-4">
<ReleaseCourusel
content={data.monday}
sectionTitle={"Понедельник"}
/>
<ReleaseCourusel
content={data.tuesday}
sectionTitle={"Вторник"}
/>
<ReleaseCourusel
content={data.wednesday}
sectionTitle={"Среда"}
/>
<ReleaseCourusel
content={data.thursday}
sectionTitle={"Четверг"}
/>
<ReleaseCourusel content={data.friday} sectionTitle={"Пятница"} />
<ReleaseCourusel
content={data.saturday}
sectionTitle={"Суббота"}
/>
<ReleaseCourusel
content={data.sunday}
sectionTitle={"Воскресенье"}
/>
</div>
: "Ошибка загрузки"}
</ModalBody>
</Modal>
);
};

View file

@ -1,26 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useSWRfetcher } from "#/api/utils";
import { useUserStore } from "#/store/auth";
import { ReleaseCourusel } from "../ReleaseCourusel/ReleaseCourusel";
import useSWR from "swr";
export const RecommendedCarousel = () => {
const token = useUserStore((state) => state.token);
const { data, isLoading, error } = useSWR(
token ? `${ENDPOINTS.discover.recommendations}/-1?previous_page=-1&token=${token}` : null,
useSWRfetcher,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
);
if (!token) return <></>;
if (error) return <></>;
if (isLoading) return <></>;
return <ReleaseCourusel content={data.content} sectionTitle={"Рекомендации"} showAllLink={"/discovery/recommendations"} />;
};

View file

@ -1,25 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useSWRfetcher } from "#/api/utils";
import { useUserStore } from "#/store/auth";
import { ReleaseCourusel } from "../ReleaseCourusel/ReleaseCourusel";
import useSWR from "swr";
export const WatchingNowCarousel = () => {
const token = useUserStore((state) => state.token);
const { data, isLoading, error } = useSWR(
`${ENDPOINTS.discover.watching}/0${token ? `?token=${token}` : ""}`,
useSWRfetcher,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
);
if (error) return <></>;
if (isLoading) return <></>;
return <ReleaseCourusel content={data.content} sectionTitle={"Смотрят сейчас"} showAllLink={"/discovery/watching"} />;
};

View file

@ -1,175 +0,0 @@
"use client";
import {
Avatar,
Dropdown,
DropdownDivider,
DropdownItem,
} from "flowbite-react";
import { useUserStore } from "#/store/auth";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { usePreferencesStore } from "#/store/preferences";
const NavbarItems = [
{
title: "Домашняя",
icon: "mdi--home",
href: "/",
auth: false,
},
{
title: "Поиск",
icon: "mdi--search",
href: "/search",
auth: false,
},
{
title: "Закладки",
icon: "mdi--bookmark-multiple",
href: "/bookmarks",
auth: true,
},
];
const FifthButton = {
favorites: {
title: "Избранное",
icon: "mdi--favorite",
href: "/favorites",
auth: true,
},
collections: {
title: "Коллекции",
icon: "mdi--collections-bookmark",
href: "/collections",
auth: true,
},
history: {
title: "История",
icon: "mdi--history",
href: "/history",
auth: true,
},
discovery: {
title: "Обзор",
icon: "mdi--compass",
href: "/discovery",
auth: false,
},
};
export const NavBarMobile = (props: { setIsSettingModalOpen: any }) => {
const userStore = useUserStore();
const router = useRouter();
const pathname = usePathname();
const preferenceStore = usePreferencesStore();
return (
<>
<footer className="fixed bottom-0 left-0 right-0 z-50 block w-full h-[70px] font-medium text-white bg-black rounded-t-lg lg:hidden">
<div className="flex items-center justify-center h-full gap-4">
{NavbarItems.map((item) => {
if (item.auth && !userStore.isAuth) return;
return (
<Link
href={item.href}
key={`navbar-mobile-${item.title}`}
className="flex flex-col items-center justify-center gap-1"
>
<span className={`iconify ${item.icon} w-6 h-6`}></span>
<span className="text-sm">{item.title}</span>
</Link>
);
})}
{preferenceStore.flags.showFifthButton ?
<>
{(
!userStore.isAuth &&
FifthButton[preferenceStore.flags.showFifthButton].auth
) ?
<></>
: <Link
href={FifthButton[preferenceStore.flags.showFifthButton].href}
key={`navbar-mobile-fifthbutton-${FifthButton[preferenceStore.flags.showFifthButton].title}`}
className="flex flex-col items-center justify-center gap-1"
>
<span
className={`iconify ${FifthButton[preferenceStore.flags.showFifthButton].icon} w-6 h-6`}
></span>
<span className="text-sm">
{FifthButton[preferenceStore.flags.showFifthButton].title}
</span>
</Link>
}
</>
: ""}
<Dropdown
arrowIcon={false}
inline
label={
<div className="flex flex-col items-center justify-center gap-1">
<Avatar
alt=""
img={
userStore.isAuth ?
userStore.user.avatar
: "https://s.anixmirai.com/avatars/no_avatar.jpg"
}
rounded
size="xs"
/>
<p className="text-sm">
{userStore.isAuth ? userStore.user.login : "Аноним"}
</p>
</div>
}
>
{userStore.isAuth && (
<>
<DropdownItem
onClick={() => router.push(`/profile/${userStore.user.id}`)}
>
<span className="w-4 h-4 iconify mdi--user"></span>
<span className="ml-2">Профиль</span>
</DropdownItem>
{Object.entries(FifthButton).map(([key, item]) => {
if (item.auth && !userStore.isAuth) return;
if (preferenceStore.flags.showFifthButton === key) return;
return (
<DropdownItem
key={`navbar-mobile-${item.title}`}
onClick={() => router.push(item.href)}
>
<span className={`w-4 h-4 iconify ${item.icon}`}></span>
<span className="ml-2">{item.title}</span>
</DropdownItem>
);
})}
<DropdownDivider />
</>
)}
<DropdownItem
onClick={() => props.setIsSettingModalOpen(true)}
className="relative flex"
>
<span className="w-4 h-4 iconify mdi--settings"></span>
<span className="ml-2">Настройки</span>
</DropdownItem>
{userStore.isAuth ?
<DropdownItem onClick={() => userStore.logout()}>
<span className="w-4 h-4 iconify mdi--logout"></span>
<span className="ml-2">Выйти</span>
</DropdownItem>
: <DropdownItem
onClick={() => router.push(`/login?redirect=${pathname}`)}
>
<span className="w-4 h-4 iconify mdi--login"></span>
<span className="ml-2">Войти</span>
</DropdownItem>
}
</Dropdown>
</div>
</footer>
</>
);
};

View file

@ -1,139 +0,0 @@
"use client";
import {
Avatar,
Dropdown,
DropdownDivider,
DropdownItem,
} from "flowbite-react";
import { useUserStore } from "#/store/auth";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
const NavbarItems = [
{
title: "Домашняя",
icon: "mdi--home",
href: "/",
auth: false,
},
{
title: "Обзор",
icon: "mdi--compass",
href: "/discovery",
auth: false,
},
{
title: "Поиск",
icon: "mdi--search",
href: "/search",
auth: false,
},
{
title: "Закладки",
icon: "mdi--bookmark-multiple",
href: "/bookmarks",
auth: true,
},
{
title: "Избранное",
icon: "mdi--favorite",
href: "/favorites",
auth: true,
},
{
title: "Коллекции",
icon: "mdi--collections-bookmark",
href: "/collections",
auth: true,
},
{
title: "История",
icon: "mdi--history",
href: "/history",
auth: true,
},
];
export const NavBarPc = (props: { setIsSettingModalOpen: any }) => {
const userStore = useUserStore();
const router = useRouter();
const pathname = usePathname();
return (
<>
<header className="sticky top-0 left-0 right-0 z-50 hidden w-full h-16 font-medium text-white bg-black rounded-b-lg lg:block">
<div className="container flex items-center justify-between h-full px-2 mx-auto">
<div className="flex items-center h-full gap-3">
{NavbarItems.map((item) => {
if (item.auth && !userStore.isAuth) return;
return (
<Link
href={item.href}
key={`navbar-pc-${item.title}`}
className="flex items-center"
>
<span className={`iconify ${item.icon} w-6 h-6`}></span>
<span className="ml-1">{item.title}</span>
</Link>
);
})}
</div>
<Dropdown
arrowIcon={false}
inline
label={
<div className="flex items-center">
<Avatar
alt=""
img={
userStore.isAuth ?
userStore.user.avatar
: "https://s.anixmirai.com/avatars/no_avatar.jpg"
}
rounded
size="xs"
/>
<p className="ml-2">
{userStore.isAuth ? userStore.user.login : "Аноним"}
</p>
</div>
}
>
{userStore.isAuth ?
<DropdownItem
onClick={() => router.push(`/profile/${userStore.user.id}`)}
className="relative flex"
>
<span className="w-4 h-4 iconify mdi--user"></span>
<span className="ml-2">Профиль</span>
</DropdownItem>
: ""}
<DropdownItem
onClick={() => props.setIsSettingModalOpen(true)}
className="relative flex"
>
<span className="w-4 h-4 iconify mdi--settings"></span>
<span className="ml-2">Настройки</span>
</DropdownItem>
{userStore.isAuth ?
<DropdownItem
onClick={() => userStore.logout()}
className="relative flex"
>
<span className="w-4 h-4 iconify mdi--logout"></span>
<span className="ml-2">Выйти</span>
</DropdownItem>
: <DropdownItem
onClick={() => router.push(`/login?redirect=${pathname}`)}
className="relative flex"
>
<span className="w-4 h-4 iconify mdi--login"></span>
<span className="ml-2">Войти</span>
</DropdownItem>
}
</Dropdown>
</div>
</header>
</>
);
};

View file

@ -0,0 +1,215 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useUserStore } from "#/store/auth";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { SettingsModal } from "#/components/SettingsModal/SettingsModal";
import { usePreferencesStore } from "#/store/preferences";
export const Navbar = () => {
const pathname = usePathname();
const userStore = useUserStore();
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const preferenceStore = usePreferencesStore();
const menuItems = [
{
id: 1,
title: "Домашняя",
href: "/",
hrefInCategory: "/home",
icon: {
default: "material-symbols--home-outline",
active: "material-symbols--home",
},
isAuthRequired: false,
isShownOnMobile: true,
},
{
id: 2,
title: "Поиск",
href: "/search",
icon: {
default: "material-symbols--search",
active: "material-symbols--search",
},
isAuthRequired: false,
isShownOnMobile: true,
},
{
id: 3,
title: "Закладки",
href: "/bookmarks",
icon: {
default: "material-symbols--bookmarks-outline",
active: "material-symbols--bookmarks",
},
isAuthRequired: true,
isShownOnMobile: true,
},
{
id: 4,
title: "Избранное",
href: "/favorites",
icon: {
default: "material-symbols--favorite-outline",
active: "material-symbols--favorite",
},
isAuthRequired: true,
isShownOnMobile: false,
},
{
id: 5,
title: "Коллекции",
href: "/collections",
icon: {
default: "material-symbols--collections-bookmark-outline",
active: "material-symbols--collections-bookmark",
},
isAuthRequired: true,
isShownOnMobile: false,
},
{
id: 6,
title: "История",
href: "/history",
icon: {
default: "material-symbols--history",
active: "material-symbols--history",
},
isAuthRequired: true,
isShownOnMobile: false,
},
];
return (
<>
<header className="fixed bottom-0 left-0 z-50 w-full text-white bg-black rounded-t-lg sm:sticky sm:top-0 sm:rounded-t-none sm:rounded-b-lg">
<div className={`container flex items-center min-h-[76px] justify-center ${preferenceStore.flags.showFifthButton && preferenceStore.flags.showNavbarTitles == "always" ? "gap-0" : "gap-4"} mx-auto sm:gap-0 sm:justify-between`}>
<div className={`flex items-center ${preferenceStore.flags.showFifthButton && preferenceStore.flags.showNavbarTitles == "always" ? "gap-4" : "gap-8"} px-2 py-4 sm:gap-4`}>
{menuItems.map((item) => {
return (
<Link
href={item.href}
key={`navbar__${item.id}`}
className={`flex-col items-center justify-center gap-1 lg:flex-row ${
item.isAuthRequired && !userStore.isAuth ? "hidden"
: item.isShownOnMobile ? "flex"
: "hidden sm:flex"
} ${[item.href, item.hrefInCategory].includes("/" + pathname.split("/")[1]) ? "font-bold" : "font-medium"} transition-all`}
>
<span
className={`w-6 h-6 iconify ${[item.href, item.hrefInCategory].includes("/" + pathname.split("/")[1]) ? item.icon.active : item.icon.default}`}
></span>
<span
className={`text-xs sm:text-base ${preferenceStore.flags.showNavbarTitles == "always" || preferenceStore.flags.showNavbarTitles == "links" || (preferenceStore.flags.showNavbarTitles == "selected" && [item.href, item.hrefInCategory].includes("/" + pathname.split("/")[1])) ? "block" : "hidden"}`}
>
{item.title}
</span>
</Link>
);
})}
</div>
<div className={`flex items-center ${preferenceStore.flags.showFifthButton && preferenceStore.flags.showNavbarTitles == "always" ? "gap-4" : "gap-8"} px-2 py-4 sm:gap-4`}>
{!userStore.isAuth ?
<Link
href={
pathname != "/login" ? `/login?redirect=${pathname}` : "#"
}
className={`flex items-center flex-col lg:flex-row gap-1 ${pathname == "/login" ? "font-bold" : "font-medium"} transition-all`}
>
<span className="w-6 h-6 iconify material-symbols--login"></span>
<span
className={`text-xs sm:text-base ${preferenceStore.flags.showNavbarTitles == "always" || (preferenceStore.flags.showNavbarTitles == "selected" && pathname == "/login") ? "block" : "hidden"}`}
>
Войти
</span>
</Link>
: <>
<Link
href={`/profile/${userStore.user.id}`}
className={`hidden lg:flex flex-col lg:flex-row items-center gap-1 ${pathname == `/profile/${userStore.user.id}` ? "font-bold" : "font-medium"} transition-all`}
>
<Image
src={userStore.user.avatar}
alt=""
className="w-6 h-6 rounded-full"
width={24}
height={24}
/>
<span
className={`text-xs sm:text-base ${preferenceStore.flags.showNavbarTitles == "always" || preferenceStore.flags.showNavbarTitles == "links" || (preferenceStore.flags.showNavbarTitles == "selected" && pathname == `/profile/${userStore.user.id}`) ? "block" : "hidden"}`}
>
{userStore.user.login}
</span>
</Link>
{preferenceStore.flags.showFifthButton ?
<Link
href={menuItems[preferenceStore.flags.showFifthButton].href}
className={`flex flex-col sm:hidden items-center gap-1 ${pathname == menuItems[preferenceStore.flags.showFifthButton].href ? "font-bold" : "font-medium"} transition-all`}
>
<span
className={`w-6 h-6 iconify ${pathname == menuItems[preferenceStore.flags.showFifthButton].href ? menuItems[preferenceStore.flags.showFifthButton].icon.active : menuItems[preferenceStore.flags.showFifthButton].icon.default}`}
></span>
<span
className={`text-xs sm:text-base ${preferenceStore.flags.showNavbarTitles == "always" || preferenceStore.flags.showNavbarTitles == "links" || (preferenceStore.flags.showNavbarTitles == "selected" && pathname == menuItems[preferenceStore.flags.showFifthButton].href) ? "block" : "hidden"}`}
>
{menuItems[preferenceStore.flags.showFifthButton].title}
</span>
</Link>
: ""}
<Link
href={`/menu`}
className={`flex flex-col lg:hidden items-center gap-1 ${pathname == `/menu` || pathname == `/profile/${userStore.user.id}` ? "font-bold" : "font-medium"} transition-all`}
>
<Image
src={userStore.user.avatar}
alt=""
className="w-6 h-6 rounded-full"
width={24}
height={24}
/>
<span
className={`text-xs sm:text-base ${preferenceStore.flags.showNavbarTitles == "always" || preferenceStore.flags.showNavbarTitles == "links" || (preferenceStore.flags.showNavbarTitles == "selected" && (pathname == `/menu` || pathname == `/profile/${userStore.user.id}`)) ? "block" : "hidden"}`}
>
{userStore.user.login}
</span>
</Link>
</>
}
<button
className={`${userStore.isAuth ? "hidden lg:flex" : "flex"} flex-col items-center gap-1 lg:flex-row`}
onClick={() => setIsSettingModalOpen(true)}
>
<span className="w-6 h-6 iconify material-symbols--settings-outline-rounded"></span>
<span
className={`text-xs sm:text-base ${preferenceStore.flags.showNavbarTitles == "always" ? "block" : "hidden"}`}
>
Настройки
</span>
</button>
{userStore.isAuth && (
<button
className="flex-col items-center hidden gap-1 lg:flex-row lg:flex"
onClick={() => userStore.logout()}
>
<span className="w-6 h-6 iconify material-symbols--logout"></span>
<span
className={`text-xs sm:text-base ${preferenceStore.flags.showNavbarTitles == "always" ? "lg:hidden xl:block" : "hidden"}`}
>
Выйти
</span>
</button>
)}
</div>
</div>
</header>
<SettingsModal
isOpen={isSettingModalOpen}
setIsOpen={setIsSettingModalOpen}
/>
</>
);
};

View file

@ -3,21 +3,12 @@ import Link from "next/link";
import ApexCharts from "apexcharts";
import { useEffect } from "react";
import { minutesToTime } from "#/api/utils";
import { ReleaseInfoSearchLink } from "../ReleaseInfo/ReleaseInfo.SearchLink";
type preferredItem = {
name: string;
percentage: number;
};
export const ProfileStats = (props: {
lists: Array<number>;
watched_count: number;
watched_time: number;
profile_id: number;
preferred_genres: Array<preferredItem>;
preferred_audiences: Array<preferredItem>;
preferred_themes: Array<preferredItem>;
profile_id: number
}) => {
const getChartOptions = () => {
return {
@ -90,95 +81,41 @@ export const ProfileStats = (props: {
</div>
<div className="flex items-center">
<div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-y-2 gap-x-4">
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#66bb6c]"></span>{" "}
Смотрю <span className="font-bold">{props.lists[0]}</span>
</p>
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#b566bb]"></span>{" "}
В планах <span className="font-bold">{props.lists[1]}</span>
</p>
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#5c6cc0]"></span>{" "}
Просмотрено <span className="font-bold">{props.lists[2]}</span>
</p>
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#ffca28]"></span>{" "}
Отложено <span className="font-bold">{props.lists[3]}</span>
</p>
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#ef5450]"></span>{" "}
Брошено <span className="font-bold">{props.lists[4]}</span>
</p>
</div>
<div className="mt-4">
<p>
Жанры:{" "}
<span>
{props.preferred_genres.map((item, index) => {
return (
<div key={`preferred-genres-${item.name}`} className="inline">
{index > 0 && ", "}
<ReleaseInfoSearchLink
title={item.name}
searchBy={"tag"}
/>{" "}
<span className="text-sm text-gray-700 dark:text-gray-300">{item.percentage}%</span>
</div>
);
})}
</span>
</p>
<p>
Аудитория:{" "}
<span>
{props.preferred_audiences.map((item, index) => {
return (
<div key={`preferred-audiences-${item.name}`} className="inline">
{index > 0 && ", "}
<ReleaseInfoSearchLink
title={item.name}
searchBy={"tag"}
/>{" "}
<span className="text-sm text-gray-700 dark:text-gray-300">{item.percentage}%</span>
</div>
);
})}
</span>
</p>
<p>
Тематика:{" "}
<span>
{props.preferred_themes.map((item, index) => {
return (
<div key={`preferred-themes-${item.name}`} className="inline">
{index > 0 && ", "}
<ReleaseInfoSearchLink
title={item.name}
searchBy={"tag"}
/>{" "}
<span className="text-sm text-gray-700 dark:text-gray-300">{item.percentage}%</span>
</div>
);
})}
</span>
</p>
<p>
Просмотрено серий:{" "}
<span className="font-bold">{props.watched_count}</span>
</p>
<p>
Время просмотра:{" "}
<span className="font-bold">
~{minutesToTime(props.watched_time)}
</span>
</p>
</div>
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#66bb6c]"></span>{" "}
Смотрю <span className="font-bold">{props.lists[0]}</span>
</p>
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#b566bb]"></span>{" "}
В планах <span className="font-bold">{props.lists[1]}</span>
</p>
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#5c6cc0]"></span>{" "}
Просмотрено <span className="font-bold">{props.lists[2]}</span>
</p>
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#ffca28]"></span>{" "}
Отложено <span className="font-bold">{props.lists[3]}</span>
</p>
<p className="align-center whitespace-nowrap">
<span className="inline-block rounded w-4 h-4 bg-[#ef5450]"></span>{" "}
Брошено <span className="font-bold">{props.lists[4]}</span>
</p>
</div>
<div id="donut-chart"></div>
</div>
<div>
<p>
Просмотрено серий:{" "}
<span className="font-bold">{props.watched_count}</span>
</p>
<p>
Время просмотра:{" "}
<span className="font-bold">
~{minutesToTime(props.watched_time, "daysHours")}
</span>
</p>
</div>
</Card>
);
};

View file

@ -15,7 +15,7 @@ export const RelatedSection = (props: any) => {
<div className="flex items-center justify-center p-4">
{props.images.map((item, index) => {
return (
<div key={`related-img-${index}`} className="w-[100px] lg:w-[300px] aspect-[9/12] even:scale-110 shadow-md even:[box-shadow:_0px_0px_16px_black;] even:z-20 origin-center first:[transform:translateX(25%)] last:[transform:translateX(-25%)] rounded-lg overflow-hidden">
<div key={`related-img-${index}`} className="w-[100px] lg:w-[300px] aspect-[9/12] even:scale-110 shadow-md even:[box-shadow:_0px_0px_16px_black;] even:z-30 origin-center first:[transform:translateX(25%)] last:[transform:translateX(-25%)] rounded-lg overflow-hidden">
<Image
fill={true}
src={item}

View file

@ -40,6 +40,12 @@ export const ReleaseCourusel = (props: {
prevEl: ".swiper-button-prev"
}}
allowTouchMove={true}
breakpoints={{
1800: {
initialSlide: 2,
centeredSlides: true
}
}}
className={Styles.swiper}
>
{props.content.map((release) => {

View file

@ -59,7 +59,7 @@ export const ReleaseInfoInfo = (props: {
{"/"}
{props.episodes.total ? props.episodes.total + " эп. " : "? эп. "}
{props.duration != 0 &&
`по ${minutesToTime(props.duration)}`}
`по ${minutesToTime(props.duration, "daysHours")}`}
</TableCell>
</TableRow>
<TableRow>

View file

@ -1,11 +1,19 @@
import Link from "next/link";
// const searchBy = {
// title: 0,
// studio: 1,
// director: 2,
// author: 3,
// genre: 4
// }
// TODO: сделать какую-нибудь анимацию на ссылке при наведении и фокусе
export const ReleaseInfoSearchLink = (props: { title: string, searchBy: string }) => {
export const ReleaseInfoSearchLink = (props: { title: string, searchBy: string | number | null }) => {
return (
<Link
className="text-gray-700 transition-colors duration-300 hover:text-black dark:text-gray-300 hover:dark:text-white"
href={`/search?query=${props.title}&params={"where"%3A"releases"%2C"searchBy"%3A"${props.searchBy}"}`}
className="underline"
href={`/search?q=${props.title}&searchBy=${props.searchBy}`}
>
{props.title}
</Link>

View file

@ -83,7 +83,7 @@ export const PosterWithStuff = (props: {
return (
<span
key={`release_${props.id}_genre_${genre}_${index}`}
className="hidden font-light leading-none text-white sm:inline md:text-sm lg:text-base xl:text-lg"
className="font-light leading-none text-white md:text-sm lg:text-base xl:text-lg"
>
{index > 0 && ", "}
{genre}
@ -91,18 +91,18 @@ export const PosterWithStuff = (props: {
);
})}
{props.title_ru && (
<p className="text-xl font-bold leading-none text-white md:text-2xl md:py-0 line-clamp-2 lg:line-clamp-3">
<p className="py-1 text-xl font-bold leading-none text-white md:text-2xl md:py-0">
{props.title_ru}
</p>
)}
{props.title_original && (
<p className="hidden mt-2 text-sm leading-none text-gray-300 sm:[display:-webkit-box] md:text-base line-clamp-2">
<p className="text-sm leading-none text-gray-300 md:text-base">
{props.title_original}
</p>
)}
</div>
{settings.showDescription && props.description && (
<p className="hidden mt-2 text-sm font-light leading-none text-white sm:block lg:text-base xl:text-lg line-clamp-4">
<p className="mt-2 text-sm font-light leading-none text-white lg:text-base xl:text-lg line-clamp-4">
{props.description}
</p>
)}

View file

@ -14,7 +14,7 @@ export const ReleaseSection = (props: {
</div>
)}
<div className="m-4">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3 2xl:grid-cols-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{props.content.map((release) => {
return (
<div key={release.id} className="w-full h-full">
@ -23,8 +23,8 @@ export const ReleaseSection = (props: {
chipsSettings={{
enabled: true,
lastWatchedHidden:
props.sectionTitle &&
props.sectionTitle.toLowerCase() != "история",
(props.sectionTitle &&
props.sectionTitle.toLowerCase() != "история")
}}
/>
</div>

View file

@ -35,11 +35,17 @@ const BookmarksCategory = {
abandoned: "Заброшено",
};
const NavbarTitles = {
always: "Всегда",
links: "Только ссылки",
selected: "Только выбранные",
never: "Никогда",
};
const FifthButton = {
favorites: "Избранное",
collections: "Коллекции",
history: "История",
discovery: "Обзор",
3: "Избранное",
4: "Коллекции",
5: "История",
};
export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
@ -50,8 +56,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
const [isPlayerConfigured, setIsPlayerConfigured] = useState(false);
useEffect(() => {
const NEXT_PUBLIC_PLAYER_PARSER_URL =
env("NEXT_PUBLIC_PLAYER_PARSER_URL") || null;
const NEXT_PUBLIC_PLAYER_PARSER_URL = env("NEXT_PUBLIC_PLAYER_PARSER_URL") || null;
if (NEXT_PUBLIC_PLAYER_PARSER_URL) {
setIsPlayerConfigured(true);
}
@ -171,8 +176,35 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
</div>
</>
: ""}
<div className="flex items-center justify-between">
<p className=" dark:text-white max-w-96">
Показывать название пункта в навигации
</p>
<Dropdown
color="blue"
label={NavbarTitles[preferenceStore.flags.showNavbarTitles]}
>
{Object.keys(NavbarTitles).map(
(key: "always" | "links" | "selected" | "never") => {
return (
<DropdownItem
className={`${key == "links" ? "hidden lg:flex" : ""}`}
key={`navbar-titles-${key}`}
onClick={() =>
preferenceStore.setFlags({
showNavbarTitles: key,
})
}
>
{NavbarTitles[key]}
</DropdownItem>
);
}
)}
</Dropdown>
</div>
{userStore.isAuth ?
<div className="flex items-center justify-between lg:hidden">
<div className="flex items-center justify-between sm:hidden">
<p className=" dark:text-white max-w-96">
Пятый пункт в навигации
</p>
@ -198,7 +230,9 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
<DropdownItem
key={`navbar-fifthbutton-${key}`}
onClick={() =>
preferenceStore.setFlags({ showFifthButton: key })
preferenceStore.setFlags({
showFifthButton: Number(key) as 3 | 4 | 5,
})
}
>
{FifthButton[key]}
@ -228,7 +262,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
<div className="flex items-center justify-between">
<div>
<p className=" dark:text-white">Сохранять историю просмотра</p>
<p className="max-w-sm text-sm text-gray-500 dark:text-gray-300">
<p className="max-w-sm text-gray-500 dark:text-gray-300">
При отключении, история не будет сохранятся как локально, так и
на аккаунте
</p>
@ -251,7 +285,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
<div className="flex items-center justify-between">
<div>
<p className=" dark:text-white">Новый плеер</p>
<p className="text-sm text-gray-500 dark:text-gray-300">
<p className="text-gray-500 dark:text-gray-300">
Поддерживаемые источники: Kodik, Sibnet, Libria
</p>
</div>

View file

@ -12,19 +12,20 @@ export const UserSection = (props: { sectionTitle?: string; content: any }) => {
</div>
)}
<div className="m-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="flex flex-wrap gap-4">
{props.content.map((user) => {
return (
<Link href={`/profile/${user.id}`} key={user.id}>
<Card>
<div className="flex items-center gap-4">
<Avatar img={user.avatar} alt="" size="lg" rounded={true} className="flex-shrink-0"/>
<p className="text-xl font-medium text-gray-900 dark:text-white line-clamp-1">{user.login}</p>
</div>
<Link href={`/profile/${user.id}`} key={user.id} className="w-full max-w-[234px] h-full max-h-[234px] aspect-square flex-shrink-0">
<Card className="items-center justify-center w-full h-full">
<Avatar img={user.avatar} alt={user.login || ""} size="lg" rounded={true} />
<h5 className="mb-1 text-xl font-medium text-gray-900 dark:text-white">
{user.login}
</h5>
</Card>
</Link>
);
})}
{props.content.length == 1 && <div></div>}
</div>
</div>
</section>

View file

@ -1,12 +0,0 @@
import { DiscoverCollectionsPage } from "#/pages/DiscoverCollections";
export const metadata = {
title: "Обзор - Коллекции",
description: "",
};
export const dynamic = "force-static";
export default function Discover() {
return <DiscoverCollectionsPage />;
}

View file

@ -1,12 +0,0 @@
import { DiscoverFilterPage } from "#/pages/DiscoverFilter";
export const metadata = {
title: "Фильтр",
description: "Поиск по фильтру",
};
export const dynamic = "force-static";
export default function Discover() {
return <DiscoverFilterPage />;
}

View file

@ -1,12 +0,0 @@
import { DiscoverPage } from "#/pages/Discover";
export const metadata = {
title: "Обзор",
description: "Рекомендации и популярное",
};
export const dynamic = "force-static";
export default function Discover() {
return <DiscoverPage />;
}

View file

@ -1,12 +0,0 @@
import { DiscoverRecommendationsPage } from "#/pages/DiscoverRecommendations";
export const metadata = {
title: "Обзор - Рекомендации",
description: "",
};
export const dynamic = "force-static";
export default function Discover() {
return <DiscoverRecommendationsPage />;
}

View file

@ -1,12 +0,0 @@
import { DiscoverWatchingPage } from "#/pages/DiscoverWatching";
export const metadata = {
title: "Обзор - Смотрят сейчас",
description: "Релизы которые сейчас смотрят",
};
export const dynamic = "force-static";
export default function Discover() {
return <DiscoverWatchingPage />;
}

View file

@ -2,7 +2,6 @@ import "./globals.css";
import { App } from "./App";
import { ThemeModeScript } from "flowbite-react";
import { PublicEnvScript } from 'next-runtime-env';
import { ThemeInit } from "../.flowbite-react/init";
export const metadata = {
metadataBase: new URL("https://anix.wah.su"),
@ -36,7 +35,6 @@ export default function RootLayout({ children }) {
<html lang="en" suppressHydrationWarning>
<head>
<PublicEnvScript />
<ThemeInit />
<ThemeModeScript />
</head>
<App>{children}</App>

11
app/menu/page.tsx Normal file
View file

@ -0,0 +1,11 @@
export const metadata = {
title: "Меню",
};
import { MenuPage } from "#/pages/MobileMenuPage";
export const dynamic = "force-static";
export default function Index() {
return <MenuPage />;
}

View file

@ -82,7 +82,7 @@ export function BookmarksCategoryPage(props: any) {
<Spinner />
</div>
);
}
};
if (error) {
return (
@ -105,9 +105,7 @@ export function BookmarksCategoryPage(props: any) {
className="flex-1 max-w-full mx-4"
onSubmit={(e) => {
e.preventDefault();
router.push(
`/search?query=${searchVal}&params={"where"%3A"list"%2C"searchBy"%3A"${props.slug}"}`
);
router.push(`/search?q=${searchVal}&where=list&list=${props.slug}`);
}}
>
<label

View file

@ -54,9 +54,7 @@ export function CollectionsPage() {
className="flex-1 max-w-full mx-4 mb-4"
onSubmit={(e) => {
e.preventDefault();
router.push(
`/search?query=${searchVal}&params={"where"%3A"collections_fav"%2C"searchBy"%3A"none"}`
);
router.push(`/search?q=${searchVal}&where=collections`);
}}
>
<label

View file

@ -1,70 +0,0 @@
"use client";
import { CollectionsOfTheWeek } from "#/components/Discovery/CollectionsOfTheWeek";
import { DiscussingToday } from "#/components/Discovery/DiscussingToday";
import { InterestingCarousel } from "#/components/Discovery/InterestingCarousel";
import { FiltersModal } from "#/components/Discovery/Modal/FiltersModal";
import { PopularModal } from "#/components/Discovery/Modal/PopularModal";
import { ScheduleModal } from "#/components/Discovery/Modal/ScheduleModal";
import { RecommendedCarousel } from "#/components/Discovery/RecommendedCarousel";
import { WatchingNowCarousel } from "#/components/Discovery/WatchingNowCarousel";
import { Button } from "flowbite-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export const DiscoverPage = () => {
const router = useRouter();
const [PopularModalOpen, setPopularModalOpen] = useState(false);
const [ScheduleModalOpen, setScheduleModalOpen] = useState(false);
const [FiltersModalOpen, setFiltersModalOpen] = useState(false);
return (
<>
<InterestingCarousel />
<div className="grid grid-cols-2 gap-4 my-4 lg:grid-cols-4">
<Button
size="xl"
color="yellow"
onClick={() => setPopularModalOpen(true)}
>
<span className="flex-shrink-0 inline-block w-8 h-8 mr-2 iconify mdi--fire"></span>
<span>Популярное</span>
</Button>
<Button
size="xl"
color="blue"
onClick={() => setScheduleModalOpen(true)}
>
<span className="flex-shrink-0 inline-block w-8 h-8 mr-2 iconify mdi--calendar-month"></span>
<span>Расписание</span>
</Button>
<Button
size="xl"
color="purple"
onClick={() => router.push("/discovery/collections?sort=recent")}
>
<span className="flex-shrink-0 inline-block w-8 h-8 mr-2 iconify mdi--collections-bookmark"></span>
<span>Коллекции</span>
</Button>
<Button
size="xl"
color="green"
onClick={() => setFiltersModalOpen(true)}
>
<span className="flex-shrink-0 inline-block w-8 h-8 mr-2 iconify mdi--mixer-settings"></span>
<span>Фильтр</span>
</Button>
</div>
<RecommendedCarousel />
<DiscussingToday />
<WatchingNowCarousel />
<CollectionsOfTheWeek />
<PopularModal isOpen={PopularModalOpen} setIsOpen={setPopularModalOpen} />
<ScheduleModal
isOpen={ScheduleModalOpen}
setIsOpen={setScheduleModalOpen}
/>
<FiltersModal isOpen={FiltersModalOpen} setIsOpen={setFiltersModalOpen} />
</>
);
};

View file

@ -1,154 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useSWRfetcher } from "#/api/utils";
import { CollectionsSection } from "#/components/CollectionsSection/CollectionsSection";
import { Spinner } from "#/components/Spinner/Spinner";
import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "#/store/auth";
import { Button, ButtonGroup } from "flowbite-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import useSWRInfinite from "swr/infinite";
export const DiscoverCollectionsSort = [
{
id: "all_time_popular",
name: "Популярные за всё время",
where: 1,
sort: 1,
},
{
id: "year_popular",
name: "Популярные за год",
where: 1,
sort: 2,
},
{
id: "season_popular",
name: "Популярные за сезон",
where: 1,
sort: 3,
},
{
id: "week_popular",
name: "Популярные за неделю",
where: 1,
sort: 4,
},
{
id: "recent",
name: "Недавно добавленные",
where: 1,
sort: 5,
},
{
id: "random",
name: "Случайные",
where: 1,
sort: 6,
},
];
export const DiscoverCollectionsPage = () => {
const userStore = useUserStore();
const searchParams = useSearchParams();
const router = useRouter();
const [previousPage, setPreviousPage] = useState(0);
const [selectedSort, setSelectedSort] = useState(
searchParams.get("sort") || "recent"
);
const [content, setContent] = useState(null);
const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.content.length) return null;
let where = null;
let sort = null;
let obj = null;
obj = DiscoverCollectionsSort.find((item) => item.id == selectedSort);
if (obj) {
where = obj.where;
sort = obj.sort;
} else {
where = DiscoverCollectionsSort[5].where;
sort = DiscoverCollectionsSort[5].sort;
}
let url: string;
url = `${ENDPOINTS.discover.collections}/${pageIndex}?where=${where}&sort=${sort}&previous_page=${previousPage}`;
if (userStore.token) {
url += `&token=${userStore.token}`;
}
return url;
};
const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey,
useSWRfetcher
);
useEffect(() => {
router.replace("/discovery/collections?sort=" + selectedSort);
setContent(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSort]);
useEffect(() => {
if (data) {
let allContent = [];
for (let i = 0; i < data.length; i++) {
allContent.push(...data[i].content);
}
setContent(allContent);
}
}, [data]);
const scrollPosition = useScrollPosition();
useEffect(() => {
if (scrollPosition >= 98 && scrollPosition <= 99) {
setPreviousPage(size);
setSize(size + 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollPosition]);
return (
<>
<div className="mb-4 overflow-x-auto">
<ButtonGroup>
{Object.entries(DiscoverCollectionsSort).map(([key, item]) => {
return (
<Button
key={`discover-collections-sort-${item.id}`}
onClick={() => setSelectedSort(item.id)}
color={selectedSort == item.id ? "blue" : "light"}
>
{item.name}
</Button>
);
})}
</ButtonGroup>
</div>
{error ?
<main className="flex items-center justify-center min-h-screen">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">Ошибка</h1>
<p className="text-lg">
Произошла ошибка при загрузке коллекций. Попробуйте обновить
страницу или зайдите позже.
</p>
</div>
</main>
: isLoading || !content ?
<div className="flex flex-col items-center justify-center min-w-full min-h-screen">
<Spinner />
</div>
: <CollectionsSection content={content} sectionTitle={"Коллекции"} />}
</>
);
};

View file

@ -1,145 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { FilterDefault, tryCatchAPI } from "#/api/utils";
import { FiltersModal } from "#/components/Discovery/Modal/FiltersModal";
import { ReleaseSection } from "#/components/ReleaseSection/ReleaseSection";
import { Spinner } from "#/components/Spinner/Spinner";
import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "#/store/auth";
import { Button } from "flowbite-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import useSWRInfinite from "swr/infinite";
const postFetcher = async (url: string, payload: string) => {
const { data, error } = await tryCatchAPI(
fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: payload,
})
);
if (error) {
throw error;
}
return data;
};
export const DiscoverFilterPage = () => {
const userStore = useUserStore();
const searchParams = useSearchParams();
const router = useRouter();
const [filter, setFilter] = useState(null);
const [content, setContent] = useState(null);
const [FooterH, setFooterH] = useState(null);
const [FiltersModalOpen, setFiltersModalOpen] = useState(false);
useEffect(() => {
const queryParams = searchParams.get("filter");
if (queryParams) {
try {
const _filter = JSON.parse(queryParams);
if (Object.keys(_filter).length != Object.keys(FilterDefault).length) {
setFilter(FilterDefault);
} else {
setFilter(_filter);
}
} catch (e) {
setFilter(FilterDefault);
}
} else {
setFilter(FilterDefault);
}
if (window) {
setFooterH(document.querySelector("footer").clientHeight + 16);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setContent(null);
const url = new URL(`/discovery/filter`, window.location.origin);
url.searchParams.set("filter", JSON.stringify(filter));
router.replace(url.toString());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter]);
const getKey = (pageIndex: number, previousPageData: any) => {
if (!filter) return null;
if (previousPageData && !previousPageData.content.length) return null;
let url = `${ENDPOINTS.filter}/${pageIndex}`;
if (userStore.token) {
url += `?token=${userStore.token}`;
}
return [url, JSON.stringify(filter)];
};
const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey,
([url, payload]) => postFetcher(url, payload),
{ initialSize: 2 }
);
useEffect(() => {
if (data) {
let _content = [];
for (let i = 0; i < data.length; i++) {
_content.push(...data[i].content);
}
setContent(_content);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
const scrollPosition = useScrollPosition();
useEffect(() => {
if (scrollPosition >= 98 && scrollPosition <= 99) {
setSize(size + 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollPosition]);
if (!filter) return <></>;
return (
<div>
{error ?
<div className="flex flex-col justify-between w-full p-4 border border-red-200 rounded-md md:flex-row bg-red-50 dark:bg-red-700 dark:border-red-600">
<div className="mb-4 md:mb-0 md:me-4">
<p>Произошла ошибка фильтра</p>
</div>
</div>
: <></>}
{content ?
<ReleaseSection content={content} sectionTitle={"Фильтр"} />
: <></>}
{isLoading ?
<div className="flex items-center justify-center w-full h-16">
<Spinner />
</div>
: ""}
<Button
color="green"
pill
className="fixed bottom-[var(--header-height)] right-4"
style={{ "--header-height": `${FooterH}px` } as React.CSSProperties}
onClick={() => setFiltersModalOpen(true)}
>
<span className="flex-shrink-0 inline-block w-8 h-8 iconify mdi--mixer-settings"></span>
</Button>
<FiltersModal
isOpen={FiltersModalOpen}
setIsOpen={setFiltersModalOpen}
filter={filter}
setFilter={setFilter}
/>
</div>
);
};

View file

@ -1,84 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useSWRfetcher } from "#/api/utils";
import { ReleaseSection } from "#/components/ReleaseSection/ReleaseSection";
import { Spinner } from "#/components/Spinner/Spinner";
import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "#/store/auth";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import useSWRInfinite from "swr/infinite";
export const DiscoverRecommendationsPage = () => {
const userStore = useUserStore();
const router = useRouter();
const [previousPage, setPreviousPage] = useState(0);
const [content, setContent] = useState(null);
const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.content.length) return null;
let url: string;
url = `${ENDPOINTS.discover.recommendations}/${pageIndex}?previous_page=${previousPage}&token=${userStore.token}`;
return url;
};
const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey,
useSWRfetcher
);
useEffect(() => {
if (data) {
let allContent = [];
for (let i = 0; i < data.length; i++) {
allContent.push(...data[i].content);
}
setContent(allContent);
}
}, [data]);
const scrollPosition = useScrollPosition();
useEffect(() => {
if (scrollPosition >= 98 && scrollPosition <= 99) {
setPreviousPage(size);
setSize(size + 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollPosition]);
useEffect(() => {
if (userStore.state === "finished" && !userStore.token) {
router.push(`/login?redirect=/discovery/recommendations`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userStore.state, userStore.token]);
return (
<>
{error ?
<main className="flex items-center justify-center min-h-screen">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">Ошибка</h1>
<p className="text-lg">
Произошла ошибка при загрузке рекомендаций. Попробуйте обновить
страницу или зайдите позже.
</p>
</div>
</main>
: !content ?
<div className="flex flex-col items-center justify-center min-w-full min-h-screen">
<Spinner />
</div>
: <ReleaseSection content={content} sectionTitle={"Рекомендации"} />}
{content && isLoading ?
<div className="flex flex-col items-center justify-center min-w-full min-h-16">
<Spinner />
</div>
: ""}
</>
);
};

View file

@ -1,79 +0,0 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useSWRfetcher } from "#/api/utils";
import { ReleaseSection } from "#/components/ReleaseSection/ReleaseSection";
import { Spinner } from "#/components/Spinner/Spinner";
import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "#/store/auth";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import useSWRInfinite from "swr/infinite";
export const DiscoverWatchingPage = () => {
const userStore = useUserStore();
const router = useRouter();
const [content, setContent] = useState(null);
const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.content.length) return null;
let url: string;
url = `${ENDPOINTS.discover.watching}/${pageIndex}`;
if (userStore.token) {
url += `?token=${userStore.token}`;
}
return url;
};
const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey,
useSWRfetcher
);
useEffect(() => {
if (data) {
let allContent = [];
for (let i = 0; i < data.length; i++) {
allContent.push(...data[i].content);
}
setContent(allContent);
}
}, [data]);
const scrollPosition = useScrollPosition();
useEffect(() => {
if (scrollPosition >= 98 && scrollPosition <= 99) {
setSize(size + 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollPosition]);
return (
<>
{error ?
<main className="flex items-center justify-center min-h-screen">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">Ошибка</h1>
<p className="text-lg">
Произошла ошибка при загрузке. Попробуйте обновить страницу или
зайдите позже.
</p>
</div>
</main>
: !content ?
<div className="flex flex-col items-center justify-center min-w-full min-h-screen">
<Spinner />
</div>
: <ReleaseSection content={content} sectionTitle={"Смотрят сейчас"} />}
{content && isLoading ?
<div className="flex flex-col items-center justify-center min-w-full min-h-16">
<Spinner />
</div>
: ""}
</>
);
};

View file

@ -69,9 +69,7 @@ export function FavoritesPage() {
className="flex-1 max-w-full mx-4 mb-4"
onSubmit={(e) => {
e.preventDefault();
router.push(
`/search?query=${searchVal}&params={"where"%3A"favorites"%2C"searchBy"%3A"none"}`
);
router.push(`/search?q=${searchVal}&where=favorites`);
}}
>
<label
@ -131,9 +129,9 @@ export function FavoritesPage() {
<DropdownItem key={index} onClick={() => setSelectedSort(index)}>
<span
className={`w-6 h-6 iconify ${
sort.values[index].value.split("_")[1] == "descending" ?
sort.descendingIcon
: sort.ascendingIcon
sort.values[index].value.split("_")[1] == "descending"
? sort.descendingIcon
: sort.ascendingIcon
}`}
></span>
{item.name}
@ -141,17 +139,18 @@ export function FavoritesPage() {
))}
</Dropdown>
</div>
{content && content.length > 0 ?
{content && content.length > 0 ? (
<ReleaseSection content={content} />
: isLoading ?
) : isLoading ? (
<div className="flex flex-col items-center justify-center min-w-full min-h-screen">
<Spinner />
</div>
: <div className="flex flex-col items-center justify-center min-w-full gap-4 mt-12 text-xl">
) : (
<div className="flex flex-col items-center justify-center min-w-full gap-4 mt-12 text-xl">
<span className="w-24 h-24 iconify-color twemoji--broken-heart"></span>
<p>В избранном пока ничего нет...</p>
</div>
}
)}
{data &&
data[data.length - 1].current_page <
data[data.length - 1].total_page_count && (

View file

@ -10,6 +10,7 @@ import { Button } from "flowbite-react";
import { useRouter } from "next/navigation";
import { useSWRfetcher } from "#/api/utils";
export function HistoryPage() {
const token = useUserStore((state) => state.token);
const authState = useUserStore((state) => state.state);
@ -61,9 +62,7 @@ export function HistoryPage() {
className="flex-1 max-w-full mx-4 mb-4"
onSubmit={(e) => {
e.preventDefault();
router.push(
`/search?query=${searchVal}&params={"where"%3A"history"%2C"searchBy"%3A"none"}`
);
router.push(`/search?q=${searchVal}&where=history`);
}}
>
<label
@ -107,7 +106,7 @@ export function HistoryPage() {
</button>
</div>
</form>
{content && content.length > 0 ?
{content && content.length > 0 ? (
<>
<ReleaseSection sectionTitle="История" content={content} />
{data && data[0].total_count != content.length && (
@ -123,15 +122,16 @@ export function HistoryPage() {
</Button>
)}
</>
: isLoading ?
) : isLoading ? (
<div className="flex flex-col items-center justify-center min-w-full min-h-[100dvh]">
<Spinner />
</div>
: <div className="flex flex-col items-center justify-center min-w-full gap-4 mt-12 text-xl">
) : (
<div className="flex flex-col items-center justify-center min-w-full gap-4 mt-12 text-xl">
<span className="w-24 h-24 iconify-color twemoji--broken-heart"></span>
<p>В истории пока ничего нет...</p>
</div>
}
)}
</>
);
}

View file

@ -3,22 +3,15 @@ import { ReleaseCourusel } from "#/components/ReleaseCourusel/ReleaseCourusel";
import { Spinner } from "#/components/Spinner/Spinner";
import { useUserStore } from "#/store/auth";
import { useState, useEffect } from "react";
import { FetchFilter } from "#/api/utils";
import { _FetchHomePageReleases } from "#/api/utils";
import { usePreferencesStore } from "#/store/preferences";
import { useRouter } from "next/navigation";
import {
ListAnnounce,
ListFilms,
ListFinished,
ListLast,
ListOngoing,
} from "./IndexFilters";
export function IndexPage() {
const token = useUserStore((state) => state.token);
const preferenceStore = usePreferencesStore();
const router = useRouter();
const router = useRouter()
const [isLoading, setIsLoading] = useState(true);
const [lastReleasesData, setLastReleasesData] = useState(null);
const [ongoingReleasesData, setOngoingReleasesData] = useState(null);
@ -28,9 +21,7 @@ export function IndexPage() {
useEffect(() => {
if (preferenceStore.params.skipToCategory.enabled) {
router.push(
`/home/${preferenceStore.params.skipToCategory.homeCategory}`
);
router.push(`/home/${preferenceStore.params.skipToCategory.homeCategory}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -44,19 +35,11 @@ export function IndexPage() {
setAnnounceReleasesData(null);
setFilmsReleasesData(null);
const [lastReleases] = await FetchFilter(ListLast.filter, 0, token);
const [ongoingReleases] = await FetchFilter(ListOngoing.filter, 0, token);
const [announceReleases] = await FetchFilter(
ListAnnounce.filter,
0,
token
);
const [finishedReleases] = await FetchFilter(
ListFinished.filter,
0,
token
);
const [filmsReleases] = await FetchFilter(ListFilms.filter, 0, token);
const lastReleases = await _FetchHomePageReleases("last", token);
const ongoingReleases = await _FetchHomePageReleases("ongoing", token);
const finishedReleases = await _FetchHomePageReleases("finished", token);
const announceReleases = await _FetchHomePageReleases("announce", token);
const filmsReleases = await _FetchHomePageReleases("films", token);
setLastReleasesData(lastReleases);
setOngoingReleasesData(ongoingReleases);
@ -73,12 +56,16 @@ export function IndexPage() {
return (
<>
{lastReleasesData && (
{lastReleasesData ? (
<ReleaseCourusel
sectionTitle="Последние релизы"
showAllLink="/home/last"
content={lastReleasesData.content}
/>
) : (
<div className="flex items-center justify-center min-w-full min-h-screen">
<Spinner />
</div>
)}
{finishedReleasesData && (
<ReleaseCourusel
@ -108,11 +95,15 @@ export function IndexPage() {
content={filmsReleasesData.content}
/>
)}
{isLoading && (
<div className="flex items-center justify-center h-32 min-w-full">
<Spinner />
</div>
)}
{!isLoading &&
!lastReleasesData &&
!finishedReleasesData &&
!ongoingReleasesData &&
!announceReleasesData && (
<div className="flex items-center justify-center min-w-full min-h-screen">
<h1 className="text-2xl">Ошибка загрузки контента...</h1>
</div>
)}
</>
);
}

View file

@ -4,10 +4,9 @@ import { Spinner } from "#/components/Spinner/Spinner";
import { useState, useEffect } from "react";
import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "../store/auth";
import { FetchFilter } from "#/api/utils";
import { _FetchHomePageReleases } from "#/api/utils";
import { Button, ButtonGroup } from "flowbite-react";
import { useRouter } from "next/navigation";
import { slugToFilter } from "./IndexFilters";
export function IndexCategoryPage(props) {
const token = useUserStore((state) => state.token);
@ -21,7 +20,7 @@ export function IndexCategoryPage(props) {
setIsLoading(true);
setContent(null);
const [ data ] = await FetchFilter(slugToFilter[props.slug].filter, page, token);
const data: any = await _FetchHomePageReleases(props.slug, token, page);
setContent(data.content);
setIsLoading(false);
@ -33,7 +32,7 @@ export function IndexCategoryPage(props) {
useEffect(() => {
async function _loadNextReleasesPage() {
const [ data ] = await FetchFilter(slugToFilter[props.slug].filter, page, token);
const data: any = await _FetchHomePageReleases(props.slug, token, page);
const newContent = [...content, ...data.content];
setContent(newContent);
}

View file

@ -1,34 +0,0 @@
import { FilterDefault } from "#/api/utils";
export const ListLast = {
name: "Последнее",
filter: FilterDefault,
};
export const ListOngoing = {
name: "Онгоинги",
filter: { ...FilterDefault, status_id: 2 },
};
export const ListAnnounce = {
name: "Анонсы",
filter: { ...FilterDefault, status_id: 3 },
};
export const ListFinished = {
name: "Завершённые",
filter: { ...FilterDefault, status_id: 1 },
};
export const ListFilms = {
name: "Фильмы",
filter: { ...FilterDefault, category_id: 2 },
};
export const slugToFilter = {
last: ListLast,
ongoing: ListOngoing,
announce: ListAnnounce,
finished: ListFinished,
films: ListFilms,
}

View file

@ -0,0 +1,130 @@
"use client";
import { Card } from "flowbite-react";
import { useUserStore } from "#/store/auth";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SettingsModal } from "#/components/SettingsModal/SettingsModal";
import { useEffect, useState } from "react";
import Image from "next/image";
import { usePreferencesStore } from "#/store/preferences";
export const MenuPage = () => {
const userStore = useUserStore();
const preferenceStore = usePreferencesStore();
const router = useRouter();
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
useEffect(() => {
if (!userStore.user) {
router.push("/");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userStore.user]);
return (
<>
{userStore.user && (
<div className="fixed flex flex-col justify-end gap-2 left-4 right-4 bottom-24 sm:static">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/profile/${userStore.user.id}`}
className="flex-1 w-full min-w-full sm:w-auto sm:min-w-0"
>
<Card className="flex-1 w-full min-w-full sm:w-auto sm:min-w-0">
<div className="flex items-center gap-4">
<Image
src={userStore.user.avatar}
width={64}
height={64}
alt=""
className="w-16 h-16 rounded-full sm:w-28 sm:h-28"
/>
<div>
<p className="text-xl sm:text-2xl">
{userStore.user.login}
</p>
<p className="text-sm text-gray-400 whitespace-pre-wrap sm:text-base dark:text-gray-300">
{userStore.user.status}
</p>
</div>
</div>
</Card>
</Link>
<div className="flex flex-1 h-full gap-2 sm:flex-col">
<button
className="flex-1"
onClick={() => {
userStore.logout();
}}
>
<Card>
<div className="flex items-center justify-center gap-2 sm:justify-start">
<span
className={`iconify material-symbols--logout-rounded w-6 h-6 text-red-500`}
></span>
<p className="text-red-500">Выйти</p>
</div>
</Card>
</button>
<button
className="flex-1"
onClick={() => {
setIsSettingModalOpen(true);
}}
>
<Card className="flex-1">
<div className="flex items-center justify-center gap-2 sm:justify-start">
<span
className={`iconify material-symbols--settings-outline-rounded w-6 h-6`}
></span>
<p>Настройки</p>
</div>
</Card>
</button>
</div>
</div>
{preferenceStore.flags.showFifthButton != 3 ?
<Link href="/favorites" className="flex-1 sm:hidden">
<Card>
<div className="flex items-center gap-2">
<span
className={`iconify material-symbols--favorite-outline w-6 h-6`}
></span>
<p>Избранное</p>
</div>
</Card>
</Link>
: ""}
{preferenceStore.flags.showFifthButton != 4 ?
<Link href="/collections" className="flex-1 sm:hidden">
<Card>
<div className="flex items-center gap-2">
<span
className={`iconify material-symbols--collections-bookmark-outline w-6 h-6`}
></span>
<p>Коллекции</p>
</div>
</Card>
</Link>
: ""}
{preferenceStore.flags.showFifthButton != 5 ?
<Link href="/history" className="flex-1 sm:hidden">
<Card>
<div className="flex items-center gap-2">
<span
className={`iconify material-symbols--history w-6 h-6`}
></span>
<p>История</p>
</div>
</Card>
</Link>
: ""}
<SettingsModal
isOpen={isSettingModalOpen}
setIsOpen={setIsSettingModalOpen}
/>
</div>
)}
</>
);
};

View file

@ -164,9 +164,6 @@ export const ProfilePage = (props: any) => {
watched_count={user.watched_episode_count}
watched_time={user.watched_time}
profile_id={user.id}
preferred_genres={user.preferred_genres || []}
preferred_audiences={user.preferred_audiences || []}
preferred_themes={user.preferred_themes || []}
/>
<ProfileWatchDynamic watchDynamic={user.watch_dynamics || []} />
<div className="flex flex-col gap-2 lg:hidden">

View file

@ -243,7 +243,7 @@ export function SearchPage() {
return [url, JSON.stringify({ query, searchBy })];
};
const { data, error, isLoading, size, setSize } = useSWRInfinite(
const { data, error, isLoading, size, setSize, mutate } = useSWRInfinite(
getKey,
([url, payload]) => postFetcher(url, payload),
{ initialSize: 2 }
@ -279,7 +279,7 @@ export function SearchPage() {
return (
<div>
<div
className="sticky top-0 sm:top-[var(--header-height)] z-30 flex flex-wrap w-full gap-2 bg-black bg-opacity-25 py-2 px-2 rounded-lg backdrop-blur-sm"
className="sticky top-0 sm:top-[var(--header-height)] z-50 flex flex-wrap w-full gap-2 bg-black bg-opacity-25 py-2 px-2 rounded-lg backdrop-blur-sm"
style={{ "--header-height": `${HeaderH}px` } as React.CSSProperties}
>
<div className="flex flex-col flex-1 w-full lg:flex-row">

View file

@ -9,7 +9,8 @@ interface preferencesState {
// saveSearchHistory: boolean;
saveWatchHistory?: boolean;
showChangelog?: boolean;
showFifthButton?: null | string;
showNavbarTitles?: "always" | "links" | "selected" | "never";
showFifthButton?: null | 3 | 4 | 5;
};
params: {
isFirstLaunch?: boolean;
@ -38,9 +39,11 @@ export const usePreferencesStore = create<preferencesState>()(
(set, get) => ({
_hasHydrated: false,
flags: {
// saveSearchHistory: true,
saveWatchHistory: true,
showChangelog: true,
showFifthButton: "discovery",
showNavbarTitles: "always",
showFifthButton: null,
},
params: {
isFirstLaunch: true,
@ -77,7 +80,6 @@ export const usePreferencesStore = create<preferencesState>()(
persistedState as preferencesState
);
},
version: 2,
},
}
)
);

View file

@ -1,5 +1,4 @@
import withFlowbiteReact from "flowbite-react/plugin/nextjs";
const withFlowbiteReact = require("flowbite-react/plugin/nextjs");
/** @type {import('next').NextConfig} */
const NextConfig = {
output: "standalone",
@ -81,4 +80,6 @@ const NextConfig = {
},
};
export default withFlowbiteReact(NextConfig);
const config = withFlowbiteReact(NextConfig);
module.exports = config;

9
package-lock.json generated
View file

@ -7,11 +7,12 @@
"": {
"name": "new",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"apexcharts": "^3.52.0",
"deepmerge-ts": "^7.1.0",
"flowbite": "^2.4.1",
"flowbite-react": "^0.12.7",
"flowbite-react": "^0.11.7",
"hls-video-element": "^1.5.0",
"markdown-to-jsx": "^7.4.7",
"media-chrome": "^4.9.0",
@ -3347,9 +3348,9 @@
}
},
"node_modules/flowbite-react": {
"version": "0.12.7",
"resolved": "https://registry.npmjs.org/flowbite-react/-/flowbite-react-0.12.7.tgz",
"integrity": "sha512-d8GR7mnCfdIl4n5RXxz4dKin6DIEA7Ax9mXDpJhz9gwxaPKUklKJZKtQ+KkdmFNrB65Zy76Pam01yr3LcxlseA==",
"version": "0.11.7",
"resolved": "https://registry.npmjs.org/flowbite-react/-/flowbite-react-0.11.7.tgz",
"integrity": "sha512-Z8m+ycHEsXPacSAi8P4yYDeff7LvcHNwbGAnL/+Fpiv+6ZWDEAGY/YPKhUofZsZa837JTYrbcbmgjqQ1bpt51g==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "1.6.9",

View file

@ -7,13 +7,14 @@
"dev-with-services": "node ./run-all.dev.js",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"postinstall": "flowbite-react patch"
},
"dependencies": {
"apexcharts": "^3.52.0",
"deepmerge-ts": "^7.1.0",
"flowbite": "^2.4.1",
"flowbite-react": "^0.12.7",
"flowbite-react": "^0.11.7",
"hls-video-element": "^1.5.0",
"markdown-to-jsx": "^7.4.7",
"media-chrome": "^4.9.0",

View file

@ -1,21 +0,0 @@
# 3.9.0
## Добавлено
- Статистика тематика, жанры и аудитория в статистике профиля
- Страница "Обзор"
## Изменено
- Секции карточек релизов теперь в 2 колонки на телефонах
- Вид карточек в поиске пользователей
- По стандарту пятой кнопкой в мобильном навбаре стоит пункт "обзор"
## Исправлено
- Неправильное время просмотра в статистике профиле в некоторых случаях
- Ссылки на переход в поиск с страницы релиза, закладок, избранных, истории, коллекциях теперь работают для нового поиска
## Удалено
- Настройки показа названий страниц в навигации

View file

@ -11,7 +11,7 @@ module.exports = {
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
".flowbite-react/class-list.json"
".flowbite-react\\class-list.json"
],
plugins: [
addIconSelectors(["mdi", "material-symbols", "twemoji", "fa6-brands", "solar"]),

View file

@ -31,7 +31,7 @@
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx",
"next.config.mjs"
"next.config.js"
],
"exclude": ["node_modules", "player-parser", "api-prox"]
}