Compare commits

...

32 commits

Author SHA1 Message Date
a4ecc27874
try fix styles
Some checks failed
Build and Publish 'anix-api-prox' to Docker Hub / publish (push) Has been cancelled
Build and Publish 'anix' to Docker Hub / publish (push) Has been cancelled
2025-08-28 22:46:15 +05:00
7e99062c0c
render ThemeInti at Root 2025-08-28 22:27:28 +05:00
284e262527
anix/ci: fix build 2025-08-28 22:13:53 +05:00
7f007480ed
anix/chore: bump version to 3.9.0 2025-08-28 21:58:28 +05:00
88664a86d1
api-prox/fix: discovery for not logged in users 2025-08-28 21:47:34 +05:00
09ddb71e15
anix/feat: add discovery filter page 2025-08-28 21:37:47 +05:00
0f1c61b765
anix/feat: finish Filters Modal Component 2025-08-28 20:52:43 +05:00
2a2343fed3
anix/feat: add year, season, episode duration, episode count, status and age rating filters 2025-08-28 08:13:02 +05:00
819d336540
anix/feat: add studio and source filters. add select all buttons to filters modal 2025-08-28 04:47:51 +05:00
777fb5b82b
anix/feat: add filters modal with country, category, genre and lists exclusion filters 2025-08-28 04:05:40 +05:00
d3b198c6bc
anix/feat: add schedule modal to discovery page 2025-08-27 23:54:11 +05:00
7d15eef691
anix/feat: add recommendations and watching pages to discovery 2025-08-27 23:40:28 +05:00
3d08603bc3
anix: fetch recommended in discovery only if logged in 2025-08-27 23:18:30 +05:00
bf24cd1063
anix/feat: add collections page for discovery page 2025-08-27 23:11:04 +05:00
e067336605
anix/feat: add popular modal to discovery 2025-08-27 22:25:02 +05:00
28b7ea2d6c
anix: show discovery button by default 2025-08-27 19:35:26 +05:00
a615af836b
anix/refactor: index category page data fetcher 2025-08-25 19:47:14 +05:00
fd0ce8cb94
anix/refactor: change Filter Fetch Function 2025-08-25 19:40:51 +05:00
56334893b4
anix/feat: add recommended to discovery page 2025-08-25 18:02:35 +05:00
05cb74b7f2
anix/feat: add wathing now and collections of the week to discovery page 2025-08-25 17:55:51 +05:00
b25bb4d6e9
anix/feat: add discussing releases to discovery page 2025-08-25 16:59:42 +05:00
01e2903e7b
anix/fix: navbar text on white theme 2025-08-25 08:00:42 +05:00
5d2a4cbe67
anix/remove: show title in navbar setting 2025-08-25 07:47:37 +05:00
61baffd295
anix/: add buttons to discovery page 2025-08-25 07:30:10 +05:00
bfb361a0a8
anix/feat: add interesting carousel to discovery page 2025-08-25 06:46:51 +05:00
93205fdb4e
anix/fix: search in bookmarks, user collections, user favorites, user history 2025-08-25 05:38:06 +05:00
8d2800c2f2
anix/style: change sections columns from 1 to 2 on mobile 2025-08-25 05:20:03 +05:00
6b84a312f7
anix/refactor: navbar 2025-08-25 04:35:32 +05:00
48345244f3
stats style 2025-08-25 01:51:12 +05:00
c636c843ed
anix/feat: add new stats
anix/fix: search links from release page
2025-08-25 01:48:56 +05:00
b93aeeed04
anix/style: change user card in search 2025-08-24 23:39:48 +05:00
052e649012
anix/fix: incorrect format of watched time statistic 2025-08-24 23:21:07 +05:00
65 changed files with 3196 additions and 673 deletions

View file

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

22
.flowbite-react/init.tsx Normal file
View file

@ -0,0 +1,22 @@
/* 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,7 +226,9 @@ app.get("/*path", async (req, res) => {
if (
!apiResponse ||
!apiResponse.ok ||
apiResponse.headers.get("content-type") != "application/json"
(apiResponse.headers.get("content-type") != "application/json" &&
apiResponse.headers.get("content-type") !=
"application/json;charset=UTF-8")
) {
logger.error(
`Failed to fetch: '${url.protocol}//${url.hostname}${url.pathname}', Path probably doesn't exist`
@ -277,7 +279,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(
@ -335,7 +337,9 @@ app.post("/*path", async (req, res) => {
if (
!apiResponse ||
!apiResponse.ok ||
apiResponse.headers.get("content-type") != "application/json"
(apiResponse.headers.get("content-type") != "application/json" &&
apiResponse.headers.get("content-type") !=
"application/json;charset=UTF-8")
) {
logger.error(
`Failed to post: '${url.protocol}//${url.hostname}${url.pathname}', Path probably doesn't exist`

View file

@ -1,7 +1,6 @@
"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 {
@ -14,6 +13,9 @@ 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"] });
@ -23,6 +25,7 @@ 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() {
@ -68,8 +71,8 @@ export const App = (props) => {
<body
className={`${inter.className} overflow-x-hidden dark:bg-[#0d1117] dark:text-white`}
>
<Navbar />
<main className="container px-2 pt-4 pb-24 mx-auto sm:pb-0">
<NavBarPc setIsSettingModalOpen={setIsSettingModalOpen} />
<main className="container px-2 pt-4 pb-24 mx-auto lg:pb-0">
{props.children}
</main>
<ChangelogModal
@ -123,6 +126,11 @@ 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.8.0";
export const CURRENT_APP_VERSION = "3.9.0";
import { env } from "next-runtime-env";
const NEXT_PUBLIC_API_URL = env("NEXT_PUBLIC_API_URL") || null;
@ -51,6 +51,7 @@ export const ENDPOINTS = {
}
},
filter: `${API_PREFIX}/filter`,
filterTypes: `${API_PREFIX}/type/all`,
search: {
profileList: `${API_PREFIX}/search/profile/list`,
profileHistory: `${API_PREFIX}/search/history`,
@ -74,5 +75,13 @@ 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,99 +248,519 @@ export function sinceUnixDate(unixInSeconds: number) {
);
}
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);
export function minutesToTime(min: number) {
const seconds = min * 60;
const epoch = new Date(0);
const date = new Date(seconds * 1000);
var dDisplay =
d > 0 ? `${d} ${numberDeclension(d, "день", "дня", "дней")}` : "";
var hDisplay =
h > 0 ? `${h} ${numberDeclension(h, "час", "часа", "часов")}` : "";
var mDisplay =
m > 0 ? `${m} ${numberDeclension(m, "минута", "минуты", "минут")}` : "";
const diffInMinutes =
new Date(date.getTime() - epoch.getTime()).getTime() / 1000 / 60;
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 : ""}`;
}
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;
}
const StatusList: Record<string, null | number> = {
last: null,
finished: 1,
ongoing: 2,
announce: 3,
export const FilterCountry = ["Япония", "Китай", "Южная Корея"];
export const FilterCategoryIdToString: Record<number, string> = {
1: "Сериал",
2: "Полнометражный фильм",
3: "OVA",
4: "Дорама",
};
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,
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,
genres: [],
profile_list_exclusions: [],
start_year: null,
status_id: statusId,
types: [],
is_genres_exclude_mode_enabled: false,
};
},
{
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: "Неважно",
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: "По популярности",
};
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: 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");
}
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,
})
.then((data: Object) => {
return data;
})
.catch((error) => {
console.log(error);
return null;
});
return data;
);
return [data, error];
}
export const BookmarksList = {

View file

@ -6,6 +6,12 @@
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">
<div className={`swiper ${Styles.swiper}`}>
<div className="swiper-wrapper">
{props.isMyCollections && (
<div className="swiper-slide" style={{ width: "fit-content" }}>

View file

@ -5,60 +5,43 @@ import Image from "next/image";
export const CollectionLink = (props: any) => {
return (
<Link href={`/collection/${props.id}`}>
<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
"
<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="absolute flex flex-wrap items-start justify-start gap-0.5 sm:gap-1 left-2 top-2">
{props.comment_count && (
<Chip
icon_name="material-symbols--favorite"
name_2={props.favorites_count}
icon_name="material-symbols--comment"
name_2={props.comment_count}
/>
{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>
)}
{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.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>
)}
{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>
</div>
</div>
</Link>

View file

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

View file

@ -0,0 +1,31 @@
"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

@ -0,0 +1,50 @@
"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

@ -0,0 +1,21 @@
.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

@ -0,0 +1,78 @@
"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

@ -0,0 +1,106 @@
"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

@ -0,0 +1,141 @@
"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

@ -0,0 +1,107 @@
"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

@ -0,0 +1,565 @@
"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

@ -0,0 +1,100 @@
"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

@ -0,0 +1,34 @@
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

@ -0,0 +1,82 @@
"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

@ -0,0 +1,71 @@
"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

@ -0,0 +1,26 @@
"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

@ -0,0 +1,25 @@
"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

@ -0,0 +1,175 @@
"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

@ -0,0 +1,139 @@
"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

@ -1,215 +0,0 @@
"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,12 +3,21 @@ 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
profile_id: number;
preferred_genres: Array<preferredItem>;
preferred_audiences: Array<preferredItem>;
preferred_themes: Array<preferredItem>;
}) => {
const getChartOptions = () => {
return {
@ -81,41 +90,95 @@ export const ProfileStats = (props: {
</div>
<div className="flex items-center">
<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 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>
</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-30 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-20 origin-center first:[transform:translateX(25%)] last:[transform:translateX(-25%)] rounded-lg overflow-hidden">
<Image
fill={true}
src={item}

View file

@ -40,12 +40,6 @@ 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, "daysHours")}`}
`по ${minutesToTime(props.duration)}`}
</TableCell>
</TableRow>
<TableRow>

View file

@ -1,19 +1,11 @@
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 | number | null }) => {
export const ReleaseInfoSearchLink = (props: { title: string, searchBy: string }) => {
return (
<Link
className="underline"
href={`/search?q=${props.title}&searchBy=${props.searchBy}`}
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}"}`}
>
{props.title}
</Link>

View file

@ -83,7 +83,7 @@ export const PosterWithStuff = (props: {
return (
<span
key={`release_${props.id}_genre_${genre}_${index}`}
className="font-light leading-none text-white md:text-sm lg:text-base xl:text-lg"
className="hidden font-light leading-none text-white sm:inline 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="py-1 text-xl font-bold leading-none text-white md:text-2xl md:py-0">
<p className="text-xl font-bold leading-none text-white md:text-2xl md:py-0 line-clamp-2 lg:line-clamp-3">
{props.title_ru}
</p>
)}
{props.title_original && (
<p className="text-sm leading-none text-gray-300 md:text-base">
<p className="hidden mt-2 text-sm leading-none text-gray-300 sm:[display:-webkit-box] md:text-base line-clamp-2">
{props.title_original}
</p>
)}
</div>
{settings.showDescription && props.description && (
<p className="mt-2 text-sm font-light leading-none text-white lg:text-base xl:text-lg line-clamp-4">
<p className="hidden mt-2 text-sm font-light leading-none text-white sm:block 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-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
<div className="grid grid-cols-2 gap-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,17 +35,11 @@ const BookmarksCategory = {
abandoned: "Заброшено",
};
const NavbarTitles = {
always: "Всегда",
links: "Только ссылки",
selected: "Только выбранные",
never: "Никогда",
};
const FifthButton = {
3: "Избранное",
4: "Коллекции",
5: "История",
favorites: "Избранное",
collections: "Коллекции",
history: "История",
discovery: "Обзор",
};
export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
@ -56,7 +50,8 @@ 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);
}
@ -176,35 +171,8 @@ 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 sm:hidden">
<div className="flex items-center justify-between lg:hidden">
<p className=" dark:text-white max-w-96">
Пятый пункт в навигации
</p>
@ -230,9 +198,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
<DropdownItem
key={`navbar-fifthbutton-${key}`}
onClick={() =>
preferenceStore.setFlags({
showFifthButton: Number(key) as 3 | 4 | 5,
})
preferenceStore.setFlags({ showFifthButton: key })
}
>
{FifthButton[key]}
@ -262,7 +228,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-gray-500 dark:text-gray-300">
<p className="max-w-sm text-sm text-gray-500 dark:text-gray-300">
При отключении, история не будет сохранятся как локально, так и
на аккаунте
</p>
@ -285,7 +251,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-gray-500 dark:text-gray-300">
<p className="text-sm text-gray-500 dark:text-gray-300">
Поддерживаемые источники: Kodik, Sibnet, Libria
</p>
</div>

View file

@ -12,20 +12,19 @@ export const UserSection = (props: { sectionTitle?: string; content: any }) => {
</div>
)}
<div className="m-4">
<div className="flex flex-wrap gap-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{props.content.map((user) => {
return (
<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>
<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>
</Card>
</Link>
);
})}
{props.content.length == 1 && <div></div>}
</div>
</div>
</section>

View file

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

View file

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

12
app/discovery/page.tsx Normal file
View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ 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"),
@ -35,6 +36,7 @@ export default function RootLayout({ children }) {
<html lang="en" suppressHydrationWarning>
<head>
<PublicEnvScript />
<ThemeInit />
<ThemeModeScript />
</head>
<App>{children}</App>

View file

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

View file

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

70
app/pages/Discover.tsx Normal file
View file

@ -0,0 +1,70 @@
"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

@ -0,0 +1,154 @@
"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

@ -0,0 +1,145 @@
"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

@ -0,0 +1,84 @@
"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

@ -0,0 +1,79 @@
"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,7 +69,9 @@ export function FavoritesPage() {
className="flex-1 max-w-full mx-4 mb-4"
onSubmit={(e) => {
e.preventDefault();
router.push(`/search?q=${searchVal}&where=favorites`);
router.push(
`/search?query=${searchVal}&params={"where"%3A"favorites"%2C"searchBy"%3A"none"}`
);
}}
>
<label
@ -129,9 +131,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}
@ -139,18 +141,17 @@ 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,7 +10,6 @@ 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);
@ -62,7 +61,9 @@ export function HistoryPage() {
className="flex-1 max-w-full mx-4 mb-4"
onSubmit={(e) => {
e.preventDefault();
router.push(`/search?q=${searchVal}&where=history`);
router.push(
`/search?query=${searchVal}&params={"where"%3A"history"%2C"searchBy"%3A"none"}`
);
}}
>
<label
@ -106,7 +107,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 && (
@ -122,16 +123,15 @@ 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,15 +3,22 @@ import { ReleaseCourusel } from "#/components/ReleaseCourusel/ReleaseCourusel";
import { Spinner } from "#/components/Spinner/Spinner";
import { useUserStore } from "#/store/auth";
import { useState, useEffect } from "react";
import { _FetchHomePageReleases } from "#/api/utils";
import { FetchFilter } 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);
@ -21,7 +28,9 @@ 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
}, []);
@ -35,11 +44,19 @@ export function IndexPage() {
setAnnounceReleasesData(null);
setFilmsReleasesData(null);
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);
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);
setLastReleasesData(lastReleases);
setOngoingReleasesData(ongoingReleases);
@ -56,16 +73,12 @@ 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
@ -95,15 +108,11 @@ export function IndexPage() {
content={filmsReleasesData.content}
/>
)}
{!isLoading &&
!lastReleasesData &&
!finishedReleasesData &&
!ongoingReleasesData &&
!announceReleasesData && (
<div className="flex items-center justify-center min-w-full min-h-screen">
<h1 className="text-2xl">Ошибка загрузки контента...</h1>
</div>
)}
{isLoading && (
<div className="flex items-center justify-center h-32 min-w-full">
<Spinner />
</div>
)}
</>
);
}

View file

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

34
app/pages/IndexFilters.ts Normal file
View file

@ -0,0 +1,34 @@
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

@ -1,130 +0,0 @@
"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,6 +164,9 @@ 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, mutate } = useSWRInfinite(
const { data, error, isLoading, size, setSize } = 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-50 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-30 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,8 +9,7 @@ interface preferencesState {
// saveSearchHistory: boolean;
saveWatchHistory?: boolean;
showChangelog?: boolean;
showNavbarTitles?: "always" | "links" | "selected" | "never";
showFifthButton?: null | 3 | 4 | 5;
showFifthButton?: null | string;
};
params: {
isFirstLaunch?: boolean;
@ -39,11 +38,9 @@ export const usePreferencesStore = create<preferencesState>()(
(set, get) => ({
_hasHydrated: false,
flags: {
// saveSearchHistory: true,
saveWatchHistory: true,
showChangelog: true,
showNavbarTitles: "always",
showFifthButton: null,
showFifthButton: "discovery",
},
params: {
isFirstLaunch: true,
@ -80,6 +77,7 @@ export const usePreferencesStore = create<preferencesState>()(
persistedState as preferencesState
);
},
}
version: 2,
},
)
);

View file

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

9
package-lock.json generated
View file

@ -7,12 +7,11 @@
"": {
"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.11.7",
"flowbite-react": "^0.12.7",
"hls-video-element": "^1.5.0",
"markdown-to-jsx": "^7.4.7",
"media-chrome": "^4.9.0",
@ -3348,9 +3347,9 @@
}
},
"node_modules/flowbite-react": {
"version": "0.11.7",
"resolved": "https://registry.npmjs.org/flowbite-react/-/flowbite-react-0.11.7.tgz",
"integrity": "sha512-Z8m+ycHEsXPacSAi8P4yYDeff7LvcHNwbGAnL/+Fpiv+6ZWDEAGY/YPKhUofZsZa837JTYrbcbmgjqQ1bpt51g==",
"version": "0.12.7",
"resolved": "https://registry.npmjs.org/flowbite-react/-/flowbite-react-0.12.7.tgz",
"integrity": "sha512-d8GR7mnCfdIl4n5RXxz4dKin6DIEA7Ax9mXDpJhz9gwxaPKUklKJZKtQ+KkdmFNrB65Zy76Pam01yr3LcxlseA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "1.6.9",

View file

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

21
public/changelog/3.9.0.md Normal file
View file

@ -0,0 +1,21 @@
# 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.js"
"next.config.mjs"
],
"exclude": ["node_modules", "player-parser", "api-prox"]
}