Compare commits

...

76 commits

Author SHA1 Message Date
762c2f324a
feat: add user profile comments preview
Some checks failed
V3 Preview Deployment / Deploy-Preview (push) Has been cancelled
2025-04-04 15:25:16 +05:00
5572f31c60
remove video from profile counters 2025-04-04 14:38:46 +05:00
256ecea885
feat: add profile friend view 2025-04-04 14:15:47 +05:00
0730b7c7d4
add empty states
Some checks are pending
V3 Preview Deployment / Deploy-Preview (push) Waiting to run
2025-04-04 06:04:23 +05:00
0cd74983f3
feat: add user friends preview 2025-04-04 05:58:13 +05:00
2fce051a54
feat: add counts to activity 2025-04-04 05:29:36 +05:00
339d4150b1
feat: collection activity preview 2025-04-04 05:26:08 +05:00
6cc9cdaa9e
fix: color issues
Some checks are pending
V3 Preview Deployment / Deploy-Preview (push) Waiting to run
2025-04-04 03:44:12 +05:00
442a046043
remove unused deps 2025-04-04 03:17:48 +05:00
29ae447636
Revert to "fix: next plugins use"
This reverts commit 141cb9a1ce.
2025-04-04 03:14:00 +05:00
141cb9a1ce
fix: next plugins use 2025-04-04 01:05:37 +05:00
7df11a467a
fix build fr 2025-04-04 00:44:00 +05:00
491f9b48b5
fix build and lints 2025-04-04 00:41:04 +05:00
329448c9fc
chore: migrate to new flowbite-react 2025-04-04 00:29:19 +05:00
8daab3b3c1
feat: add view of user blocklist 2025-04-03 22:56:07 +05:00
4aa48f589b
small changes 2025-04-03 22:10:58 +05:00
c4c422904e
refactor: user recently watched 2025-04-03 21:59:18 +05:00
cbdcfedb9b
refactor: return user stats & made release votes preview as a list 2025-04-03 21:38:51 +05:00
8cf1bb534d
refactor: start redesign of user page 2025-04-03 21:07:44 +05:00
68a7117a97
allow fullscreen iframe if cannot use custom player
Some checks failed
V3 Preview Deployment / Deploy-Preview (push) Has been cancelled
2025-03-29 20:19:09 +05:00
9e4bf1ac71
feat: allow to drag slider in episode selector 2025-03-29 20:17:22 +05:00
66721c6d6a
chore: update packages 2025-03-29 20:00:07 +05:00
8ab668dcd6
chore: update readme
Some checks failed
V3 Preview Deployment / Deploy-Preview (push) Has been cancelled
2025-03-27 22:17:33 +05:00
663633c979
chore: bump version [3.5.0] 2025-03-27 22:10:49 +05:00
265be8d1e1
change line-height to 1 on poster text 2025-03-27 22:03:03 +05:00
7b97b33951
feat: add fifth navbar button settings for mobile 2025-03-27 21:55:53 +05:00
1959fcd437
refactor: remove 'show only link names in navbar' in settings on mobile 2025-03-27 20:54:43 +05:00
bf67b5d928
refactor: move mobile menu to the bottom on mobile 2025-03-27 20:41:53 +05:00
2241a8a226
feat: add link to about page in settings 2025-03-27 20:27:15 +05:00
956d35579b
feat: add about page 2025-03-26 16:53:54 +05:00
04b580d239
fix: reset player error on episode change 2025-03-26 15:53:27 +05:00
07c93338cb
fix: poster aspect ration in release carousel
Some checks are pending
V3 Preview Deployment / Deploy-Preview (push) Waiting to run
2025-03-26 15:45:25 +05:00
ac2425ba55
refactor: licensed platform location 2025-03-26 15:34:58 +05:00
ba5c149779
refactor: Profile release ratings 2025-03-26 01:57:20 +05:00
f6b8202877
remove old stuff 2025-03-26 01:50:10 +05:00
5abb6e8f11
refactor: create collection page release poster 2025-03-26 01:47:47 +05:00
967b9cfbb0
refactor: profile page recently watched 2025-03-26 01:08:59 +05:00
1530fa3937
refactor: release section 2025-03-26 00:51:30 +05:00
e1e176c24b
refactor: release courusel 2025-03-26 00:08:33 +05:00
34bbcc4893
fix: link 2025-03-25 23:45:14 +05:00
4496a7280b
refactor: related releases page 2025-03-25 23:35:20 +05:00
6cade5a7d0
refactor: update style of Related Releases Card on Release page 2025-03-25 23:01:11 +05:00
d2b38dcbe2
refactor: set release poster to center on release page on mobile
BREAKING: disable all image rendering
2025-03-25 20:39:25 +05:00
b79c07f4c2
remove console.log
Some checks failed
V3 Preview Deployment / Deploy-Preview (push) Has been cancelled
2025-03-22 23:26:05 +05:00
1c5b551dc5
Merge branch 'V3' of https://github.com/Radiquum/AniX into V3 2025-03-22 23:12:18 +05:00
ba046d7d74
chore: update README 2025-03-22 23:12:12 +05:00
Kentai Radiquum
c5a1c40f51
Merge pull request #5 from Radiquum/feat__toasts
V3.4.0: TOASTS!
2025-03-22 23:10:48 +05:00
f23fa710a3
chore: bump version 2025-03-22 23:04:56 +05:00
11a52e98b5
refactor: older changelogs fetching 2025-03-22 22:51:07 +05:00
f94b551cdf
refactor: improve error handling for custom player 2025-03-22 21:04:31 +05:00
fc8fe97da7
refactor: change mobile icons spacing in navbar 2025-03-22 19:06:12 +05:00
1990e5434c
refactor: release page layout 2025-03-22 18:51:31 +05:00
6242b850c2
fix: styling 2025-03-22 18:26:47 +05:00
ee0b0f72e3
refactor: user lists style on release page 2025-03-22 18:24:48 +05:00
3b2a89b964
chore: update 3.3.0 changelog headings 2025-03-22 03:08:43 +05:00
ae92a7a424
fix: search 2025-03-22 01:56:03 +05:00
9c26654ca9
feat: add toast to user socials update 2025-03-22 01:48:33 +05:00
e3fe979714
feat: add toast to user privacy setting update 2025-03-22 01:36:14 +05:00
0bf00b11e5
refactor: move login from function to middleware 2025-03-22 01:15:48 +05:00
19dbd69fd5
fix: request to get user setting if no token exists 2025-03-22 00:40:55 +05:00
43d3aab01d
feat: add toast to user login change 2025-03-21 23:55:03 +05:00
83ad889408
feat: add toast to user status change 2025-03-21 16:03:58 +05:00
d8ebabb04e
refactor: CropModal
feat: add toasts for collection and profile image changes
2025-03-21 15:46:09 +05:00
fa1a5cbfe6
feat: add toast to friend request / user blocking 2025-03-21 02:11:57 +05:00
75ab5e1901
feat: add release to collection toast 2025-03-21 01:46:48 +05:00
3aa71acad5
feat: add collection action and release edit toasts
fix: misaligned posters in add release to collection modal
fix: collection releases reset when searching for new release to add
2025-03-21 01:25:27 +05:00
b7661f47ef
feat: add toast for creating/updating collection 2025-03-21 00:51:23 +05:00
4c6fb75785
feat: add toast for release user list change 2025-03-21 00:07:05 +05:00
60ece79df3
feat: add toast notification on favorite button click 2025-03-21 00:01:46 +05:00
f609de25f9
fix: missing alt text property errors in console 2025-03-20 23:22:29 +05:00
b10a4fabb0
refactor: fetcher -> useSWRfetcher 2025-03-20 23:15:58 +05:00
8e56a39fe1
fix: pages that use fetchDataViaGET 2025-03-20 23:04:37 +05:00
f9ba62d525
refactor: fetcher -> useSWRfetcher (Search, CreateCollection Search Modal) 2025-03-20 22:35:28 +05:00
92f6725b21
feat: update fetcher for collections 2025-03-20 22:24:20 +05:00
d16e4d14d4
feat: add error messages to user pages 2025-03-20 22:02:49 +05:00
Kentai Radiquum
f2f03df1a0
Delete TODO.md 2025-03-20 19:38:19 +05:00
101 changed files with 6124 additions and 3395 deletions

View file

@ -1,7 +1,3 @@
{ {
"extends": ["next/core-web-vitals", "prettier"], "extends": ["next/core-web-vitals"]
"rules": {
"prettier/prettier": "error"
},
"plugins": ["prettier"]
} }

2
.flowbite-react/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
class-list.json
pid

View file

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

2
.gitignore vendored
View file

@ -59,3 +59,5 @@ videos/*
!videos/*.js !videos/*.js
!videos/*.ts !videos/*.ts
public/_next-video public/_next-video
API-Trace/*

View file

@ -6,10 +6,10 @@ AniX is an unofficial web client for the Android application Anixart. It allows
## Changelog [RU] ## Changelog [RU]
- [3.5.0](./public/changelog/3.5.0.md)
- [3.4.0](./public/changelog/3.4.0.md)
- [3.3.0](./public/changelog/3.3.0.md) - [3.3.0](./public/changelog/3.3.0.md)
- [3.2.3](./public/changelog/3.2.3.md) - [3.2.3](./public/changelog/3.2.3.md)
- [3.2.2](./public/changelog/3.2.2.md)
- [3.2.1](./public/changelog/3.2.1.md)
[other versions](./public/changelog) [other versions](./public/changelog)

23
TODO.md
View file

@ -1,23 +0,0 @@
# Список TODO для AniX V3
## Профиль
- [ ] Просмотр комментариев пользователя к релизам и коллекциям.
- [ ] Просмотр друзей пользователя.
## Возможно
- Цвета Material U (MD3)
- Собственная история поиска
### Рекомендации (Обзор)
- [ ] Просмотр страницы рекомендаций
### Страница аниме тайтла
- Видео тайтла (трейлеры, опенинги)
## Другое
- [ ] документация для API Anixart

View file

@ -4,10 +4,11 @@ import { usePreferencesStore } from "./store/preferences";
import { Navbar } from "./components/Navbar/NavbarUpdate"; import { Navbar } from "./components/Navbar/NavbarUpdate";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Modal } from "flowbite-react"; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from "flowbite-react";
import { Spinner } from "./components/Spinner/Spinner"; import { Spinner } from "./components/Spinner/Spinner";
import { ChangelogModal } from "#/components/ChangelogModal/ChangelogModal"; import { ChangelogModal } from "#/components/ChangelogModal/ChangelogModal";
import PlausibleProvider from "next-plausible"; import PlausibleProvider from "next-plausible";
import { Bounce, ToastContainer } from "react-toastify";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@ -79,8 +80,8 @@ export const App = (props) => {
show={preferencesStore.params.isFirstLaunch} show={preferencesStore.params.isFirstLaunch}
onClose={() => preferencesStore.setParams({ isFirstLaunch: false })} onClose={() => preferencesStore.setParams({ isFirstLaunch: false })}
> >
<Modal.Header>Внимание</Modal.Header> <ModalHeader>Внимание</ModalHeader>
<Modal.Body> <ModalBody>
<p> <p>
Данный сайт не связан с разработчиками приложения Anixart, это Данный сайт не связан с разработчиками приложения Anixart, это
неофициальная имплементация веб клиента для этого приложения. неофициальная имплементация веб клиента для этого приложения.
@ -93,15 +94,15 @@ export const App = (props) => {
На сайте могут присутствовать ошибки и не доработки, а так-же На сайте могут присутствовать ошибки и не доработки, а так-же
отсутствующий функционал. отсутствующий функционал.
</p> </p>
</Modal.Body> </ModalBody>
<Modal.Footer> <ModalFooter>
<Button <Button
color={"blue"} color={"blue"}
onClick={() => preferencesStore.setParams({ isFirstLaunch: false })} onClick={() => preferencesStore.setParams({ isFirstLaunch: false })}
> >
Принимаю Принимаю
</Button> </Button>
</Modal.Footer> </ModalFooter>
</Modal> </Modal>
{preferencesStore.flags.enableAnalytics && ( {preferencesStore.flags.enableAnalytics && (
<PlausibleProvider <PlausibleProvider
@ -111,6 +112,20 @@ export const App = (props) => {
enabled={true} enabled={true}
/> />
)} )}
<ToastContainer
className={"mx-2 mb-20 sm:mb-0"}
position="bottom-center"
autoClose={5000}
hideProgressBar={false}
newestOnTop={true}
closeOnClick={true}
rtl={false}
pauseOnFocusLoss={false}
draggable={true}
pauseOnHover={true}
theme="colored"
transition={Bounce}
/>
</body> </body>
); );
}; };

16
app/about/page.tsx Normal file
View file

@ -0,0 +1,16 @@
export const metadata = {
title: "О приложении",
openGraph: {
title: "AniX - Неофициальный веб клиент для Anixart",
description:
"AniX - это неофициальный веб-клиент для Android-приложения Anixart. Он позволяет вам получать доступ к своей учетной записи Anixart и управлять ею из веб-браузера. Так-же можно синхронизировать и управлять списками и избранным. И самое главное смотреть все доступные аниме из базы Anixart.",
},
};
export const dynamic = "force-static";
import { AboutPage } from "#/pages/About";
export default function Index() {
return <AboutPage />;
}

View file

@ -1,4 +1,4 @@
export const CURRENT_APP_VERSION = "3.3.0"; export const CURRENT_APP_VERSION = "3.5.0";
export const API_URL = "https://api.anixart.tv"; export const API_URL = "https://api.anixart.tv";
export const API_PREFIX = "/api/proxy"; export const API_PREFIX = "/api/proxy";
@ -13,10 +13,20 @@ export const ENDPOINTS = {
licensed: `${API_PREFIX}/release/streaming/platform`, licensed: `${API_PREFIX}/release/streaming/platform`,
}, },
user: { user: {
auth: `${API_PREFIX}/auth/signIn`,
profile: `${API_PREFIX}/profile`, profile: `${API_PREFIX}/profile`,
bookmark: `${API_PREFIX}/profile/list`, bookmark: `${API_PREFIX}/profile/list`,
history: `${API_PREFIX}/history`, history: `${API_PREFIX}/history`,
favorite: `${API_PREFIX}/favorite`, favorite: `${API_PREFIX}/favorite`,
blocklist: `${API_PREFIX}/profile/blocklist`,
friend: {
list: `${API_PREFIX}/profile/friend/all`,
add: `${API_PREFIX}/profile/friend/request/send`,
remove: `${API_PREFIX}/profile/friend/request/remove`,
hide: `${API_PREFIX}/profile/friend/request/hide`,
in: `${API_PREFIX}/profile/friend/requests/in`,
out: `${API_PREFIX}/profile/friend/requests/out`,
},
settings: { settings: {
my: `${API_PREFIX}/profile/preference/my`, my: `${API_PREFIX}/profile/preference/my`,
login: { login: {

View file

@ -1,14 +0,0 @@
import { NextResponse, NextRequest } from "next/server";
import { authorize } from "#/api/utils";
import { API_URL } from "#/api/config";
export async function POST(request: NextRequest) {
const response = await authorize(`${API_URL}/auth/signIn`, await request.json());
if (!response) {
return NextResponse.json({ message: "Server Error" }, { status: 500 });
}
if (!response.profile) {
return NextResponse.json({ message: "Profile not found" }, { status: 404 });
}
return NextResponse.json(response);
}

View file

@ -49,16 +49,26 @@ export async function GET(request: NextRequest) {
if (token) { if (token) {
url.searchParams.set("token", token); url.searchParams.set("token", token);
} }
const data = { query, searchBy }; const body = { query, searchBy };
const response = await fetchDataViaPost( const { data, error } = await fetchDataViaPost(
url.toString(), url.toString(),
JSON.stringify(data), JSON.stringify(body),
true true
); );
if (!response) { if (error) {
return NextResponse.json({ message: "Bad request" }, { status: 400 }); return new Response(JSON.stringify(error), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
} }
return NextResponse.json(response); return new Response(JSON.stringify(data), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
} }

View file

@ -4,79 +4,159 @@ export const HEADERS = {
"Content-Type": "application/json; charset=UTF-8", "Content-Type": "application/json; charset=UTF-8",
}; };
type Success<T> = {
data: T;
error: null;
};
type Failure<E> = {
data: null;
error: E;
};
type Result<T, E = Error> = Success<T> | Failure<E>;
export async function tryCatch<T, E = Error>(
promise: Promise<T>
): Promise<Result<T, E>> {
try {
const data = await promise;
return { data, error: null };
} catch (error) {
return { data: null, error: error as E };
}
}
export async function tryCatchPlayer<T, E = Error>(
promise: Promise<any>
): Promise<Result<any, any>> {
try {
const res: Awaited<Response> = await promise;
const data = await res.json();
if (!res.ok) {
if (data.message) {
return {
data: null,
error: {
message: data.message,
code: res.status,
},
};
} else if (data.detail) {
return {
data: null,
error: {
message: data.detail,
code: res.status,
},
};
} else {
return {
data: null,
error: {
message: res.statusText,
code: res.status,
},
};
}
}
return { data, error: null };
} catch (error) {
return { data: null, error: error as E };
}
}
export async function tryCatchAPI<T, E = Error>(
promise: Promise<any>
): Promise<Result<any, any>> {
try {
const res: Awaited<Response> = await promise;
// if (!res.ok) {
// return {
// data: null,
// error: {
// message: res.statusText,
// code: res.status,
// },
// };
// }
if (
res.headers.get("content-length") &&
Number(res.headers.get("content-length")) == 0
) {
return {
data: null,
error: {
message: "Not Found",
code: 404,
},
};
}
const data: Awaited<any> = await res.json();
if (data.code != 0) {
return {
data: null,
error: {
message: "API Returned an Error",
code: data.code || 500,
},
};
}
return { data, error: null };
} catch (error) {
return { data: null, error: error };
}
}
export const useSWRfetcher = async (url: string) => {
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
throw error;
}
return data;
};
export const fetchDataViaGet = async ( export const fetchDataViaGet = async (
url: string, url: string,
API_V2: string | boolean = false API_V2: string | boolean = false,
addHeaders?: Record<string, any>
) => { ) => {
if (API_V2) { if (API_V2) {
HEADERS["API-Version"] = "v2"; HEADERS["API-Version"] = "v2";
} }
try {
const response = await fetch(url, { const { data, error } = await tryCatchAPI(
headers: HEADERS, fetch(url, {
}); headers: { ...HEADERS, ...addHeaders },
if (response.status !== 200) { })
return null; );
}
const data = await response.json(); return { data, error };
return data;
} catch (error) {
console.log(error);
}
}; };
export const fetchDataViaPost = async ( export const fetchDataViaPost = async (
url: string, url: string,
body: string, body: string,
API_V2: string | boolean = false, API_V2: string | boolean = false,
contentType: string = "" addHeaders?: Record<string, any>
) => { ) => {
if (API_V2) { if (API_V2) {
HEADERS["API-Version"] = "v2"; HEADERS["API-Version"] = "v2";
} }
if (contentType != "") {
HEADERS["Content-Type"] = contentType;
}
try { const { data, error } = await tryCatchAPI(
const response = await fetch(url, { fetch(url, {
method: "POST", method: "POST",
headers: HEADERS,
body: body, body: body,
}); headers: { ...HEADERS, ...addHeaders },
if (response.status !== 200) { })
return null; );
}
const data = await response.json();
return data;
} catch (error) {
console.log(error);
}
};
export const authorize = async ( return { data, error };
url: string,
data: { login: string; password: string }
) => {
try {
const response = await fetch(
`${url}?login=${data.login}&password=${data.password}`,
{
method: "POST",
headers: {
"User-Agent": USER_AGENT,
Sign: "9aa5c7af74e8cd70c86f7f9587bde23d",
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
if (response.status !== 200) {
throw new Error("Error authorizing user");
}
return await response.json();
} catch (error) {
return error;
}
}; };
export function setJWT(user_id: number | string, jwt: string) { export function setJWT(user_id: number | string, jwt: string) {
@ -132,9 +212,9 @@ export function unixToDate(
" " + " " +
date.getFullYear() + date.getFullYear() +
", " + ", " +
date.getHours() + `${date.getHours()}`.padStart(2, "0") +
":" + ":" +
date.getMinutes() `${date.getMinutes()}`.padStart(2, "0")
); );
if (type === "dayMonth") if (type === "dayMonth")
return date.getDate() + " " + months[date.getMonth()]; return date.getDate() + " " + months[date.getMonth()];

View file

@ -1,28 +1,36 @@
import { ViewCollectionPage } from "#/pages/ViewCollection"; import { ViewCollectionPage } from "#/pages/ViewCollection";
import { fetchDataViaGet } from "#/api/utils"; import { fetchDataViaGet } from "#/api/utils";
import type { Metadata, ResolvingMetadata } from "next"; import type { Metadata, ResolvingMetadata } from "next";
export const dynamic = 'force-static'; export const dynamic = "force-static";
export async function generateMetadata( export async function generateMetadata(
{ params }, { params },
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata> { ): Promise<Metadata> {
const id = params.id; const id = params.id;
const collection = await fetchDataViaGet( const { data, error } = await fetchDataViaGet(
`https://api.anixart.tv/collection/${id}` `https://api.anixart.tv/collection/${id}`
); );
const previousOG = (await parent).openGraph; const previousOG = (await parent).openGraph;
if (error) {
return {
title: "Приватная коллекция",
description: "Приватная коллекция",
};
}
return { return {
title: collection.collection title:
? "коллекция - " + collection.collection.title data.collection ?
"коллекция - " + data.collection.title
: "Приватная коллекция", : "Приватная коллекция",
description: collection.collection && collection.collection.description, description: data.collection && data.collection.description,
openGraph: { openGraph: {
...previousOG, ...previousOG,
images: [ images: [
{ {
url: collection.collection && collection.collection.image, // Must be an absolute URL url: data.collection && data.collection.image, // Must be an absolute URL
width: 600, width: 600,
height: 800, height: 800,
}, },

View file

@ -1,9 +1,18 @@
"use client"; "use client";
import { Modal, Accordion } from "flowbite-react"; import {
Accordion,
AccordionContent,
AccordionPanel,
AccordionTitle,
Modal,
ModalBody,
ModalHeader,
} from "flowbite-react";
import Markdown from "markdown-to-jsx"; import Markdown from "markdown-to-jsx";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Styles from "./ChangelogModal.module.css"; import Styles from "./ChangelogModal.module.css";
import { tryCatch } from "#/api/utils";
export const ChangelogModal = (props: { export const ChangelogModal = (props: {
isOpen: boolean; isOpen: boolean;
@ -17,54 +26,63 @@ export const ChangelogModal = (props: {
>({}); >({});
async function _fetchVersionChangelog(version: string) { async function _fetchVersionChangelog(version: string) {
const res = await fetch(`/changelog/${version}.md`); const { data, error } = await tryCatch(fetch(`/changelog/${version}.md`));
return await res.text(); if (error) {
return "Нет списка изменений";
}
return await data.text();
} }
useEffect(() => { useEffect(() => {
if (props.version != "" && currentVersionChangelog == "") { if (props.version != "" && currentVersionChangelog == "") {
setCurrentVersionChangelog("Загрузка ...");
_fetchVersionChangelog(props.version).then((data) => { _fetchVersionChangelog(props.version).then((data) => {
setCurrentVersionChangelog(data); setCurrentVersionChangelog(data);
}); });
} }
if (props.previousVersions.length > 0) {
props.previousVersions.forEach((version) => {
_fetchVersionChangelog(version).then((data) => {
setPreviousVersionsChangelog((prev) => {
return {
...prev,
[version]: data,
};
});
});
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.version]); }, [props.version]);
return ( return (
<Modal show={props.isOpen} onClose={() => props.setIsOpen(false)}> <Modal show={props.isOpen} onClose={() => props.setIsOpen(false)}>
<Modal.Header>Список изменений v{props.version}</Modal.Header> <ModalHeader>Список изменений v{props.version}</ModalHeader>
<Modal.Body> <ModalBody>
<Markdown className={Styles.markdown}> <Markdown className={Styles.markdown}>
{currentVersionChangelog} {currentVersionChangelog}
</Markdown> </Markdown>
{Object.keys(previousVersionsChangelog).length == props.previousVersions.length && ( <Accordion collapseAll={true} className="mt-4">
<Accordion collapseAll={true} className="mt-4"> {props.previousVersions.length > 0 &&
{props.previousVersions.map( props.previousVersions.map((version) => {
(version) => ( return (
<Accordion.Panel key={version}> <AccordionPanel key={version}>
<Accordion.Title>Список изменений v{version}</Accordion.Title> <AccordionTitle
<Accordion.Content> onClickCapture={(e) => {
<Markdown className={Styles.markdown}>{previousVersionsChangelog[version]}</Markdown> if (!previousVersionsChangelog.hasOwnProperty(version)) {
</Accordion.Content> _fetchVersionChangelog(version).then((data) => {
</Accordion.Panel> setPreviousVersionsChangelog((prev) => {
) return {
)} ...prev,
</Accordion> [version]: data,
)} };
</Modal.Body> });
});
}
}}
>
Список изменений v{version}
</AccordionTitle>
<AccordionContent>
{previousVersionsChangelog.hasOwnProperty(version) ?
<Markdown className={Styles.markdown}>
{previousVersionsChangelog[version]}
</Markdown>
: <div>Загрузка ...</div>}
</AccordionContent>
</AccordionPanel>
);
})}
</Accordion>
</ModalBody>
</Modal> </Modal>
); );
}; };

View file

@ -6,26 +6,22 @@ export const Chip = (props: {
devider?: string; devider?: string;
bg_color?: string; bg_color?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string;
}) => { }) => {
return ( return (
<div <div className={`${props.bg_color || "bg-gray-500"} rounded-sm flex items-center justify-center ${props.className || ""}`}>
className={`px-2 sm:px-4 py-0.5 sm:py-1 rounded-sm ${
props.bg_color || "bg-gray-500"
} ${props.icon_name ? "flex items-center justify-center gap-1" : ""}`}
style={props.style || {}}
>
{props.icon_name && ( {props.icon_name && (
<span <span
className={`iconify w-4 h-4 sm:w-6 sm:h-6 ${props.icon_name}`} className={`iconify w-4 h-4 sm:w-6 sm:h-6 ml-2 ${props.icon_name}`}
style={ style={
{ {
"color": "var(--icon-color)", color: "var(--icon-color)",
"--icon-color": props.icon_color || "#fff", "--icon-color": props.icon_color || "#fff",
} as React.CSSProperties } as React.CSSProperties
} }
></span> ></span>
)} )}
<p className="text-xs text-white xl:text-base"> <p className="px-2 py-1 text-white xl:text-base">
{props.name} {props.name}
{props.name && props.devider ? props.devider : " "} {props.name && props.devider ? props.devider : " "}
{props.name_2} {props.name_2}

View file

@ -1,9 +1,11 @@
"use client"; "use client";
import { Card, Button } from "flowbite-react"; import { Card, Button, useThemeMode } from "flowbite-react";
import { useState } from "react"; import { useState } from "react";
import { useUserStore } from "#/store/auth"; import { useUserStore } from "#/store/auth";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { tryCatchAPI } from "#/api/utils";
import { toast } from "react-toastify";
export const CollectionInfoControls = (props: { export const CollectionInfoControls = (props: {
isFavorite: boolean; isFavorite: boolean;
@ -12,36 +14,124 @@ export const CollectionInfoControls = (props: {
isPrivate: boolean; isPrivate: boolean;
}) => { }) => {
const [isFavorite, setIsFavorite] = useState(props.isFavorite); const [isFavorite, setIsFavorite] = useState(props.isFavorite);
const [isUpdating, setIsUpdating] = useState(false);
const theme = useThemeMode();
const userStore = useUserStore(); const userStore = useUserStore();
const router = useRouter(); const router = useRouter();
async function _addToFavorite() { async function _addToFavorite() {
if (userStore.user) { async function _FavCol(url: string) {
setIsFavorite(!isFavorite); setIsUpdating(true);
if (isFavorite) { const tid = toast.loading(
fetch( isFavorite ?
`${ENDPOINTS.collection.favoriteCollections}/delete/${props.id}?token=${userStore.token}` "Удаляем коллекцию из избранного..."
); : "Добавляем коллекцию в избранное...",
} else { {
fetch( position: "bottom-center",
`${ENDPOINTS.collection.favoriteCollections}/add/${props.id}?token=${userStore.token}` hideProgressBar: true,
); closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
}
);
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render:
isFavorite ?
"Ошибка удаления коллекции из избранного"
: "Ошибка добавления коллекции в избранное",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setIsUpdating(false);
return;
} }
toast.update(tid, {
render:
isFavorite ?
"Коллекция удалена из избранного"
: "Коллекция добавлена в избранное",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setIsUpdating(false);
setIsFavorite(!isFavorite);
}
if (userStore.token) {
let url = `${ENDPOINTS.collection.favoriteCollections}/add/${props.id}?token=${userStore.token}`;
if (isFavorite) {
url = `${ENDPOINTS.collection.favoriteCollections}/delete/${props.id}?token=${userStore.token}`;
}
_FavCol(url);
} }
} }
async function _deleteCollection() { async function _deleteCollection() {
if (userStore.user) { async function _DelCol(url: string) {
fetch( setIsUpdating(true);
const tid = toast.loading("Удаляем коллекцию...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render: "Ошибка удаления коллекции",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setIsUpdating(false);
return;
}
toast.update(tid, {
render: `Коллекция удалена`,
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setIsUpdating(false);
router.push("/collections");
}
if (userStore.token) {
_DelCol(
`${ENDPOINTS.collection.delete}/${props.id}?token=${userStore.token}` `${ENDPOINTS.collection.delete}/${props.id}?token=${userStore.token}`
); );
router.push("/collections");
} }
} }
return ( return (
<Card className="w-full h-fit "> <Card className="w-full h-fit ">
<Button color={"blue"} onClick={() => _addToFavorite()}> <Button
color={"blue"}
onClick={() => _addToFavorite()}
disabled={isUpdating}
>
<span <span
className={`iconify w-6 h-6 mr-2 ${ className={`iconify w-6 h-6 mr-2 ${
isFavorite ? "mdi--heart" : "mdi--heart-outline" isFavorite ? "mdi--heart" : "mdi--heart-outline"
@ -60,6 +150,7 @@ export const CollectionInfoControls = (props: {
onClick={() => onClick={() =>
router.push("/collections/create?mode=edit&id=" + props.id) router.push("/collections/create?mode=edit&id=" + props.id)
} }
disabled={isUpdating}
> >
<span className="w-6 h-6 mr-2 iconify mdi--pencil"></span>{" "} <span className="w-6 h-6 mr-2 iconify mdi--pencil"></span>{" "}
Редактировать Редактировать
@ -68,6 +159,7 @@ export const CollectionInfoControls = (props: {
color={"red"} color={"red"}
className="w-full sm:max-w-64" className="w-full sm:max-w-64"
onClick={() => _deleteCollection()} onClick={() => _deleteCollection()}
disabled={isUpdating}
> >
<span className="w-6 h-6 mr-2 iconify mdi--trash"></span> Удалить <span className="w-6 h-6 mr-2 iconify mdi--trash"></span> Удалить
</Button> </Button>

View file

@ -15,7 +15,7 @@ export const CollectionLink = (props: any) => {
<Image <Image
src={props.image} src={props.image}
fill={true} fill={true}
alt={props.title} alt={props.title || ""}
className="-z-[1] object-cover" className="-z-[1] object-cover"
sizes=" sizes="
(max-width: 768px) 300px, (max-width: 768px) 300px,
@ -49,11 +49,13 @@ export const CollectionLink = (props: any) => {
{props.title} {props.title}
</p> </p>
</div> </div>
<p className="text-xs font-light text-white md:text-sm lg:text-base xl:text-lg"> {props.description && (
{`${props.description.slice(0, 125)}${ <p className="text-xs font-light text-white md:text-sm lg:text-base xl:text-lg">
props.description.length > 125 ? "..." : "" {`${props.description.slice(0, 125)}${
}`} props.description.length > 125 ? "..." : ""
</p> }`}
</p>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import { Button, Modal, ToggleSwitch, Label, Textarea } from "flowbite-react"; import { Button, Label, Modal, ModalBody, ModalHeader, Textarea, ToggleSwitch } from "flowbite-react";
import { CommentsComment } from "./Comments.Comment"; import { CommentsComment } from "./Comments.Comment";
import { useState } from "react"; import { useState } from "react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
@ -68,12 +68,12 @@ export const CommentsAddModal = (props: {
show={props.isOpen} show={props.isOpen}
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
> >
<Modal.Header> <ModalHeader>
<p className="text-lg font-bold text-gray-900 lg:text-2xl dark:text-white"> <p className="text-lg font-bold text-gray-900 lg:text-2xl dark:text-white">
{props.isReply ? "Ответ на комментарий" : "Оставить комментарий"} {props.isReply ? "Ответ на комментарий" : "Оставить комментарий"}
</p> </p>
</Modal.Header> </ModalHeader>
<Modal.Body> <ModalBody>
{props.isReply && ( {props.isReply && (
<div className="mb-4"> <div className="mb-4">
<CommentsComment <CommentsComment
@ -98,7 +98,7 @@ export const CommentsAddModal = (props: {
<form className="flex flex-col gap-4" onSubmit={(e) => _sendComment(e)}> <form className="flex flex-col gap-4" onSubmit={(e) => _sendComment(e)}>
<div> <div>
<div className="block mb-2 sr-only"> <div className="block mb-2 sr-only">
<Label htmlFor="comment" value="Ваш комментарий." /> <Label htmlFor="comment">Ваш комментарий.</Label>
</div> </div>
<Textarea <Textarea
id="comment" id="comment"
@ -132,7 +132,7 @@ export const CommentsAddModal = (props: {
</Button> </Button>
</div> </div>
</form> </form>
</Modal.Body> </ModalBody>
</Modal> </Modal>
); );
}; };

View file

@ -1,7 +1,7 @@
import { unixToDate, sinceUnixDate } from "#/api/utils"; import { unixToDate, sinceUnixDate } from "#/api/utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { Button, Dropdown } from "flowbite-react"; import { Button, Dropdown, DropdownItem } from "flowbite-react";
import Link from "next/link"; import Link from "next/link";
import { CommentsAddModal } from "./Comments.Add"; import { CommentsAddModal } from "./Comments.Add";
import { CommentsEditModal } from "./Comments.Edit"; import { CommentsEditModal } from "./Comments.Edit";
@ -183,12 +183,12 @@ export const CommentsComment = (props: {
<span className="w-6 h-6 bg-gray-400 iconify mdi--more-horiz hover:bg-gray-800 active:bg-gray-800"></span> <span className="w-6 h-6 bg-gray-400 iconify mdi--more-horiz hover:bg-gray-800 active:bg-gray-800"></span>
)} )}
> >
<Dropdown.Item onClick={() => setIsEditCommentsOpen(true)}> <DropdownItem onClick={() => setIsEditCommentsOpen(true)}>
Редактировать Редактировать
</Dropdown.Item> </DropdownItem>
<Dropdown.Item onClick={() => _deleteComment()}> <DropdownItem onClick={() => _deleteComment()}>
Удалить Удалить
</Dropdown.Item> </DropdownItem>
</Dropdown> </Dropdown>
)} )}
</footer> </footer>

View file

@ -1,4 +1,4 @@
import { Button, Modal, ToggleSwitch, Label, Textarea } from "flowbite-react"; import { Button, Label, Modal, ModalBody, ModalHeader, Textarea, ToggleSwitch } from "flowbite-react";
import { useState } from "react"; import { useState } from "react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
@ -56,16 +56,16 @@ export const CommentsEditModal = (props: {
show={props.isOpen} show={props.isOpen}
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
> >
<Modal.Header> <ModalHeader>
<p className="text-lg font-bold text-gray-900 lg:text-2xl dark:text-white"> <p className="text-lg font-bold text-gray-900 lg:text-2xl dark:text-white">
Редактировать комментарий Редактировать комментарий
</p> </p>
</Modal.Header> </ModalHeader>
<Modal.Body> <ModalBody>
<form className="flex flex-col gap-4" onSubmit={(e) => _sendComment(e)}> <form className="flex flex-col gap-4" onSubmit={(e) => _sendComment(e)}>
<div> <div>
<div className="block mb-2 sr-only"> <div className="block mb-2 sr-only">
<Label htmlFor="comment" value="Редактировать ваш комментарий." /> <Label htmlFor="comment" >Редактировать ваш комментарий.</Label>
</div> </div>
<Textarea <Textarea
id="comment" id="comment"
@ -99,7 +99,7 @@ export const CommentsEditModal = (props: {
</Button> </Button>
</div> </div>
</form> </form>
</Modal.Body> </ModalBody>
</Modal> </Modal>
); );
}; };

View file

@ -1,9 +1,10 @@
import { Card, Button, Modal, Spinner } from "flowbite-react"; import { Button, Card, Modal, ModalHeader, Spinner } from "flowbite-react";
import { CommentsComment } from "./Comments.Comment"; import { CommentsComment } from "./Comments.Comment";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import { CommentsAddModal } from "./Comments.Add"; import { CommentsAddModal } from "./Comments.Add";
import { useSWRfetcher } from "#/api/utils";
export const CommentsMain = (props: { export const CommentsMain = (props: {
release_id: number; release_id: number;
@ -82,20 +83,6 @@ export const CommentsMain = (props: {
); );
}; };
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
const CommentsAllModal = (props: { const CommentsAllModal = (props: {
isOpen: boolean; isOpen: boolean;
setIsOpen: any; setIsOpen: any;
@ -103,7 +90,6 @@ const CommentsAllModal = (props: {
token: string | null; token: string | null;
type?: "release" | "collection"; type?: "release" | "collection";
}) => { }) => {
const [isLoadingEnd, setIsLoadingEnd] = useState(false);
const [currentRef, setCurrentRef] = useState<any>(null); const [currentRef, setCurrentRef] = useState<any>(null);
const modalRef = useCallback((ref) => { const modalRef = useCallback((ref) => {
setCurrentRef(ref); setCurrentRef(ref);
@ -127,7 +113,7 @@ const CommentsAllModal = (props: {
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2 } { initialSize: 2 }
); );
@ -139,7 +125,6 @@ const CommentsAllModal = (props: {
allReleases.push(...data[i].content); allReleases.push(...data[i].content);
} }
setContent(allReleases); setContent(allReleases);
setIsLoadingEnd(true);
} }
}, [data]); }, [data]);
@ -164,22 +149,22 @@ const CommentsAllModal = (props: {
show={props.isOpen} show={props.isOpen}
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
> >
<Modal.Header> <ModalHeader>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-gray-900 lg:text-2xl dark:text-white"> <h2 className="text-lg font-bold text-gray-900 lg:text-2xl dark:text-white">
Все комментарии Все комментарии
</h2> </h2>
<p className="text-sm font-light text-gray-600 dark:text-gray-300"> <p className="text-sm font-light text-gray-600 dark:text-gray-300">
всего: {!isLoadingEnd ? "загрузка..." : data[0].total_count} всего: {isLoading ? "загрузка..." : data[0].total_count}
</p> </p>
</div> </div>
</Modal.Header> </ModalHeader>
<div <div
className="flex flex-col gap-2 p-4 overflow-y-auto" className="flex flex-col gap-2 p-4 overflow-y-auto"
onScroll={handleScroll} onScroll={handleScroll}
ref={modalRef} ref={modalRef}
> >
{!isLoadingEnd ? ( {isLoading ? (
<Spinner /> <Spinner />
) : content ? ( ) : content ? (
content.map((comment: any) => ( content.map((comment: any) => (

View file

@ -1,58 +1,88 @@
import React, { useRef } from "react"; import React, { useRef } from "react";
import Cropper, { ReactCropperElement } from "react-cropper"; import Cropper, { ReactCropperElement } from "react-cropper";
import "cropperjs/dist/cropper.css"; import "cropperjs/dist/cropper.css";
import { Button, Modal } from "flowbite-react"; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from "flowbite-react";
type Props = { type CropModalProps = {
src: string;
setSrc: (src: string) => void;
setTempSrc: (src: string) => void;
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; isActionsDisabled: boolean;
height: number; selectedImage: any | null;
width: number; croppedImage: any | null;
aspectRatio: number; setCropModalProps: (props: {
guides: boolean; isOpen: boolean;
quality: number; isActionsDisabled: boolean;
forceAspect?: boolean; selectedImage: any | null;
croppedImage: any | null;
}) => void;
cropParams: {
guides?: boolean;
width?: number;
height?: number;
quality?: number;
aspectRatio?: number;
forceAspect?: boolean;
};
}; };
export const CropModal: React.FC<Props> = (props) => { export const CropModal: React.FC<CropModalProps> = ({
isOpen,
setCropModalProps,
cropParams,
selectedImage,
croppedImage,
isActionsDisabled,
}) => {
const cropperRef = useRef<ReactCropperElement>(null); const cropperRef = useRef<ReactCropperElement>(null);
const getCropData = () => { const getCropData = () => {
if (typeof cropperRef.current?.cropper !== "undefined") { if (typeof cropperRef.current?.cropper !== "undefined") {
props.setSrc( const croppedImage = cropperRef.current?.cropper
cropperRef.current?.cropper .getCroppedCanvas({
.getCroppedCanvas({ width: cropParams.width,
width: props.width, height: cropParams.height,
height: props.height, maxWidth: cropParams.width,
maxWidth: props.width, maxHeight: cropParams.height,
maxHeight: props.height, })
}) .toDataURL(
.toDataURL("image/jpeg", props.quality) "image/jpeg",
); cropParams.quality || false ? cropParams.quality : 100
props.setTempSrc(""); );
setCropModalProps({
isOpen: true,
isActionsDisabled: false,
selectedImage: selectedImage,
croppedImage: croppedImage,
});
} }
}; };
return ( return (
<Modal <Modal
dismissible dismissible
show={props.isOpen} show={isOpen}
onClose={() => props.setIsOpen(false)} onClose={() => {
setCropModalProps({
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
});
}}
size={"7xl"} size={"7xl"}
> >
<Modal.Header>Обрезать изображение</Modal.Header> <ModalHeader>Обрезать изображение</ModalHeader>
<Modal.Body> <ModalBody>
<Cropper <Cropper
src={props.src} src={selectedImage}
style={{ height: 400, width: "100%" }} style={{ height: 400, width: "100%" }}
responsive={true} responsive={true}
// Cropper.js options // Cropper.js options
initialAspectRatio={props.aspectRatio} initialAspectRatio={cropParams.aspectRatio || 1 / 1}
aspectRatio={props.forceAspect ? props.aspectRatio : undefined} aspectRatio={
guides={props.guides} cropParams.forceAspect || false ? cropParams.aspectRatio : undefined
}
guides={cropParams.guides || false}
ref={cropperRef} ref={cropperRef}
/> />
@ -65,29 +95,32 @@ export const CropModal: React.FC<Props> = (props) => {
</p> </p>
<p>Используйте колёсико мыши что-бы изменить масштаб</p> <p>Используйте колёсико мыши что-бы изменить масштаб</p>
</div> </div>
</Modal.Body> </ModalBody>
<Modal.Footer> <ModalFooter>
<Button <Button
color={"blue"} color={"blue"}
disabled={isActionsDisabled}
onClick={() => { onClick={() => {
getCropData(); getCropData();
props.setIsOpen(false);
}} }}
> >
Сохранить Сохранить
</Button> </Button>
<Button <Button
color={"red"} color={"red"}
disabled={isActionsDisabled}
onClick={() => { onClick={() => {
props.setSrc(null); setCropModalProps({
props.setTempSrc(null); isOpen: false,
// props.setImageData(null); isActionsDisabled: false,
props.setIsOpen(false); selectedImage: null,
croppedImage: null,
});
}} }}
> >
Удалить Отменить
</Button> </Button>
</Modal.Footer> </ModalFooter>
</Modal> </Modal>
); );
}; };

View file

@ -1,290 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserStore } from "#/store/auth";
import { Dropdown } from "flowbite-react";
import { useState } from "react";
import Image from "next/image";
import { SettingsModal } from "#/components/SettingsModal/SettingsModal";
export const Navbar = () => {
const pathname = usePathname();
const userStore: any = useUserStore((state) => state);
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const navLinks = [
{
id: 1,
icon: "material-symbols--home-outline",
iconActive: "material-symbols--home",
title: "Домашняя",
href: "/",
categoryHref: "/home",
withAuthOnly: false,
mobileMenu: false,
},
{
id: 2,
icon: "material-symbols--search",
iconActive: "material-symbols--search",
title: "Поиск",
href: "/search",
withAuthOnly: false,
mobileMenu: false,
},
{
id: 3,
icon: "material-symbols--bookmarks-outline",
iconActive: "material-symbols--bookmarks",
title: "Закладки",
href: "/bookmarks",
withAuthOnly: true,
mobileMenu: false,
},
{
id: 4,
icon: "material-symbols--favorite-outline",
iconActive: "material-symbols--favorite",
title: "Избранное",
href: "/favorites",
withAuthOnly: true,
mobileMenu: true,
},
{
id: 5,
icon: "material-symbols--collections-bookmark-outline",
iconActive: "material-symbols--collections-bookmark",
title: "Коллекции",
href: "/collections",
withAuthOnly: true,
mobileMenu: true,
},
{
id: 6,
icon: "material-symbols--history",
iconActive: "material-symbols--history",
title: "История",
href: "/history",
withAuthOnly: true,
mobileMenu: true,
},
];
return (
<>
<header className="fixed bottom-0 left-0 z-50 w-full text-white bg-black sm:sticky sm:top-0">
<div className="container flex items-center justify-center gap-4 px-4 py-4 mx-auto lg:justify-between lg:gap-0">
<nav className="flex gap-4">
{navLinks.map((link) => {
return (
<Link
key={link.id}
href={link.href}
className={`flex-col items-center lg:flex-row ${
link.withAuthOnly && !userStore.isAuth
? "hidden"
: link.mobileMenu
? "hidden sm:flex"
: "flex"
}`}
>
<span
className={`iconify ${
[link.href, link.categoryHref].includes(
"/" + pathname.split("/")[1]
)
? link.iconActive
: link.icon
} w-6 h-6`}
></span>
<span
className={`${
[link.href, link.categoryHref].includes(
"/" + pathname.split("/")[1]
)
? "font-bold"
: ""
} text-sm sm:text-base`}
>
{link.title}
</span>
</Link>
);
})}
</nav>
{userStore.isAuth ? (
<>
<div className="flex-col items-center justify-end hidden text-sm md:flex lg:gap-1 lg:justify-center lg:flex-row lg:text-base">
<Image
src={userStore.user.avatar}
alt=""
className="w-6 h-6 rounded-full"
width={24}
height={24}
/>
<Dropdown
label={userStore.user.login}
inline={true}
dismissOnClick={true}
theme={{
arrowIcon:
"ml-1 w-4 h-4 [transform:rotateX(180deg)] sm:transform-none",
floating: {
target: "text-sm sm:text-base",
},
}}
>
<Dropdown.Item className="text-sm md:text-base">
<Link
href={`/profile/${userStore.user.id}`}
className="flex items-center gap-1"
>
<span
className={`iconify ${
pathname == `/profile/${userStore.user.id}`
? "font-bold mdi--user"
: "mdi--user-outline"
} w-6 h-6`}
></span>
<span>Профиль</span>
</Link>
</Dropdown.Item>
{navLinks.map((link) => {
return (
<Dropdown.Item
key={link.id + "_mobile"}
className={`${
link.mobileMenu ? "block sm:hidden" : "hidden"
} text-sm md:text-base`}
>
<Link
href={link.href}
className={`flex items-center gap-1`}
>
<span
className={`iconify ${
[link.href, link.categoryHref].includes(
"/" + pathname.split("/")[1]
)
? link.iconActive
: link.icon
} w-6 h-6`}
></span>
<span
className={`${
[link.href, link.categoryHref].includes(
"/" + pathname.split("/")[1]
)
? "font-bold"
: ""
}`}
>
{link.title}
</span>
</Link>
</Dropdown.Item>
);
})}
<Dropdown.Item
onClick={() => {
setIsSettingModalOpen(true);
}}
className="flex items-center gap-1 text-sm md:text-base"
>
<span
className={`iconify material-symbols--settings-outline-rounded w-6 h-6`}
></span>
<span>Настройки</span>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
userStore.logout();
}}
className="flex items-center gap-1 text-sm md:text-base"
>
<span
className={`iconify material-symbols--logout-rounded w-6 h-6`}
></span>
<span>Выйти</span>
</Dropdown.Item>
</Dropdown>
</div>
<div className="block md:hidden">
<Link
href={"/menu"}
className={`flex flex-col items-center justify-end text-sm md:hidden lg:gap-1 lg:justify-center lg:flex-row lg:text-base ${
pathname == "/menu" ? "font-bold" : ""
}`}
>
<Image
src={userStore.user.avatar}
alt=""
className="w-6 h-6 rounded-full"
width={24}
height={24}
/>
<p>{userStore.user.login}</p>
</Link>
</div>
</>
) : (
<Dropdown
label=""
renderTrigger={() => (
<div className="flex flex-col items-center text-sm md:text-base">
<span className="w-6 h-6 iconify mdi--menu"></span>
<span>Меню</span>
</div>
)}
inline={true}
dismissOnClick={true}
theme={{
arrowIcon:
"ml-1 w-4 h-4 [transform:rotateX(180deg)] sm:transform-none",
}}
>
<Dropdown.Item className="text-sm md:text-base">
<Link
href={
pathname != "/login" ? `/login?redirect=${pathname}` : "#"
}
className="flex items-center gap-1"
>
<span
className={`w-6 h-6 sm:w-6 sm:h-6 iconify ${
pathname == "/login"
? "mdi--user-circle"
: "mdi--user-circle-outline"
}`}
></span>
<span
className={`${
pathname == "/login" ? "font-bold" : ""
} text-sm sm:text-base`}
>
Войти
</span>
</Link>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
setIsSettingModalOpen(true);
}}
className="flex items-center gap-1 text-sm md:text-base"
>
<span
className={`iconify material-symbols--settings-outline-rounded w-6 h-6 sm:w-6 sm:h-6`}
></span>
<span>Настройки</span>
</Dropdown.Item>
</Dropdown>
)}
</div>
</header>
<SettingsModal
isOpen={isSettingModalOpen}
setIsOpen={setIsSettingModalOpen}
/>
</>
);
};

View file

@ -87,8 +87,8 @@ export const Navbar = () => {
return ( 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"> <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 justify-center mx-auto sm:justify-between"> <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 gap-4 px-2 py-4"> <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) => { {menuItems.map((item) => {
return ( return (
<Link <Link
@ -98,7 +98,7 @@ export const Navbar = () => {
item.isAuthRequired && !userStore.isAuth ? "hidden" item.isAuthRequired && !userStore.isAuth ? "hidden"
: item.isShownOnMobile ? "flex" : item.isShownOnMobile ? "flex"
: "hidden sm:flex" : "hidden sm:flex"
} ${[item.href, item.hrefInCategory].includes("/" + pathname.split("/")[1]) ? "font-bold" : "font-medium"}`} } ${[item.href, item.hrefInCategory].includes("/" + pathname.split("/")[1]) ? "font-bold" : "font-medium"} transition-all`}
> >
<span <span
className={`w-6 h-6 iconify ${[item.href, item.hrefInCategory].includes("/" + pathname.split("/")[1]) ? item.icon.active : item.icon.default}`} className={`w-6 h-6 iconify ${[item.href, item.hrefInCategory].includes("/" + pathname.split("/")[1]) ? item.icon.active : item.icon.default}`}
@ -112,13 +112,13 @@ export const Navbar = () => {
); );
})} })}
</div> </div>
<div className="flex items-center gap-4 px-2 py-4"> <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 ? {!userStore.isAuth ?
<Link <Link
href={ href={
pathname != "/login" ? `/login?redirect=${pathname}` : "#" pathname != "/login" ? `/login?redirect=${pathname}` : "#"
} }
className={`flex items-center flex-col lg:flex-row gap-1 ${pathname == "/login" ? "font-bold" : "font-medium"}`} 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="w-6 h-6 iconify material-symbols--login"></span>
<span <span
@ -130,7 +130,7 @@ export const Navbar = () => {
: <> : <>
<Link <Link
href={`/profile/${userStore.user.id}`} 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"}`} 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 <Image
src={userStore.user.avatar} src={userStore.user.avatar}
@ -139,13 +139,30 @@ export const Navbar = () => {
width={24} width={24}
height={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"}`}> <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} {userStore.user.login}
</span> </span>
</Link> </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 <Link
href={`/menu`} href={`/menu`}
className={`flex flex-col lg:hidden items-center gap-1 ${pathname == `/menu` || pathname == `/profile/${userStore.user.id}` ? "font-bold" : "font-medium"}`} className={`flex flex-col lg:hidden items-center gap-1 ${pathname == `/menu` || pathname == `/profile/${userStore.user.id}` ? "font-bold" : "font-medium"} transition-all`}
> >
<Image <Image
src={userStore.user.avatar} src={userStore.user.avatar}
@ -154,7 +171,9 @@ export const Navbar = () => {
width={24} width={24}
height={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"}`}> <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} {userStore.user.login}
</span> </span>
</Link> </Link>
@ -165,7 +184,11 @@ export const Navbar = () => {
onClick={() => setIsSettingModalOpen(true)} onClick={() => setIsSettingModalOpen(true)}
> >
<span className="w-6 h-6 iconify material-symbols--settings-outline-rounded"></span> <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> <span
className={`text-xs sm:text-base ${preferenceStore.flags.showNavbarTitles == "always" ? "block" : "hidden"}`}
>
Настройки
</span>
</button> </button>
{userStore.isAuth && ( {userStore.isAuth && (
<button <button
@ -173,7 +196,9 @@ export const Navbar = () => {
onClick={() => userStore.logout()} onClick={() => userStore.logout()}
> >
<span className="w-6 h-6 iconify material-symbols--logout"></span> <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
className={`text-xs sm:text-base ${preferenceStore.flags.showNavbarTitles == "always" ? "lg:hidden xl:block" : "hidden"}`}
>
Выйти Выйти
</span> </span>
</button> </button>

View file

@ -1,8 +1,10 @@
"use client"; "use client";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { Card, Button } from "flowbite-react"; import { tryCatchAPI } from "#/api/utils";
import { Card, Button, useThemeMode } from "flowbite-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-toastify";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
// null - не друзья // null - не друзья
@ -24,11 +26,12 @@ export const ProfileActions = (props: {
edit_isOpen: boolean; edit_isOpen: boolean;
edit_setIsOpen: any; edit_setIsOpen: any;
}) => { }) => {
const router = useRouter();
const profileIdIsSmaller = props.my_profile_id < props.profile_id; const profileIdIsSmaller = props.my_profile_id < props.profile_id;
const [friendRequestDisabled, setFriendRequestDisabled] = useState(false); const theme = useThemeMode();
const [blockRequestDisabled, setBlockRequestDisabled] = useState(false);
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const [actionsDisabled, setActionsDisabled] = useState(false);
function _getFriendStatus() { function _getFriendStatus() {
const num = props.friendStatus; const num = props.friendStatus;
@ -54,53 +57,119 @@ export const ProfileActions = (props: {
} }
const FriendStatus = _getFriendStatus(); const FriendStatus = _getFriendStatus();
const isRequestedStatus = const isRequestedStatus =
FriendStatus != null FriendStatus != null ?
? profileIdIsSmaller profileIdIsSmaller ? profileIdIsSmaller && FriendStatus != 0
? profileIdIsSmaller && FriendStatus != 0 : !profileIdIsSmaller && FriendStatus == 2
: !profileIdIsSmaller && FriendStatus == 2 : null;
: null;
// ^ This is some messed up shit // ^ This is some messed up shit
function _addToFriends() { async function _addToFriends() {
let url = `${ENDPOINTS.user.profile}/friend/request`; setActionsDisabled(true);
setFriendRequestDisabled(true);
setBlockRequestDisabled(true);
FriendStatus == 1 const tid = toast.loading("Добавляем в друзья...", {
? (url += "/remove/") position: "bottom-center",
: isRequestedStatus hideProgressBar: true,
? (url += "/remove/") closeOnClick: false,
: (url += "/send/"); pauseOnHover: false,
draggable: false,
url += `${props.profile_id}?token=${props.token}`; theme: theme.mode == "light" ? "light" : "dark",
fetch(url).then((res) => {
mutate(
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}`
);
setTimeout(() => {
setBlockRequestDisabled(false);
setFriendRequestDisabled(false);
}, 100);
}); });
let url = `${ENDPOINTS.user.profile}/friend/request`;
FriendStatus == 1 ? (url += "/remove/")
: isRequestedStatus ? (url += "/remove/")
: (url += "/send/");
url += `${props.profile_id}?token=${props.token}`;
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render:
FriendStatus == 1 || isRequestedStatus ?
"Ошибка удаления из друзей"
: "Ошибка добавления в друзья",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setActionsDisabled(false);
return;
}
mutate(
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}`
);
toast.update(tid, {
render:
FriendStatus == 1 || isRequestedStatus ?
"Удален из друзей"
: "Добавлен в друзья",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setActionsDisabled(false);
} }
function _addToBlocklist() { async function _addToBlocklist() {
setActionsDisabled(true);
const tid = toast.loading(
!props.is_blocked ?
"Блокируем пользователя..."
: "Разблокируем пользователя...",
{
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
}
);
let url = `${ENDPOINTS.user.profile}/blocklist`; let url = `${ENDPOINTS.user.profile}/blocklist`;
setBlockRequestDisabled(true);
setFriendRequestDisabled(true);
!props.is_blocked ? (url += "/add/") : (url += "/remove/"); !props.is_blocked ? (url += "/add/") : (url += "/remove/");
url += `${props.profile_id}?token=${props.token}`; url += `${props.profile_id}?token=${props.token}`;
fetch(url).then((res) => {
mutate( const { data, error } = await tryCatchAPI(fetch(url));
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}` if (error) {
); toast.update(tid, {
setTimeout(() => { render: !props.is_blocked ? "Ошибка блокировки" : "Ошибка разблокировки",
setBlockRequestDisabled(false); type: "error",
setFriendRequestDisabled(false); autoClose: 2500,
}, 100); isLoading: false,
closeOnClick: true,
draggable: true,
});
setActionsDisabled(false);
return;
}
mutate(
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}`
);
toast.update(tid, {
render:
!props.is_blocked ?
"Пользователь заблокирован"
: "Пользователь разблокирован",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
}); });
setActionsDisabled(false);
} }
return ( return (
@ -109,7 +178,14 @@ export const ProfileActions = (props: {
<p>Отправил(-а) вам заявку в друзья</p> <p>Отправил(-а) вам заявку в друзья</p>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
{props.isMyProfile && <Button color={"blue"} onClick={() => props.edit_setIsOpen(!props.edit_isOpen)}>Редактировать</Button>} {props.isMyProfile && (
<Button
color={"blue"}
onClick={() => props.edit_setIsOpen(!props.edit_isOpen)}
>
Редактировать
</Button>
)}
{!props.isMyProfile && ( {!props.isMyProfile && (
<> <>
{(!props.isFriendRequestsDisallowed || {(!props.isFriendRequestsDisallowed ||
@ -118,26 +194,25 @@ export const ProfileActions = (props: {
!props.is_me_blocked && !props.is_me_blocked &&
!props.is_blocked && ( !props.is_blocked && (
<Button <Button
disabled={friendRequestDisabled} disabled={actionsDisabled}
color={ color={
FriendStatus == 1 FriendStatus == 1 ? "red"
? "red" : isRequestedStatus ?
: isRequestedStatus "light"
? "light" : "blue"
: "blue"
} }
onClick={() => _addToFriends()} onClick={() => _addToFriends()}
> >
{FriendStatus == 1 {FriendStatus == 1 ?
? "Удалить из друзей" "Удалить из друзей"
: isRequestedStatus : isRequestedStatus ?
? "Заявка отправлена" "Заявка отправлена"
: "Добавить в друзья"} : "Добавить в друзья"}
</Button> </Button>
)} )}
<Button <Button
color={!props.is_blocked ? "red" : "blue"} color={!props.is_blocked ? "red" : "blue"}
disabled={blockRequestDisabled} disabled={actionsDisabled}
onClick={() => _addToBlocklist()} onClick={() => _addToBlocklist()}
> >
{!props.is_blocked ? "Заблокировать" : "Разблокировать"} {!props.is_blocked ? "Заблокировать" : "Разблокировать"}

View file

@ -1,49 +1,112 @@
"use client"; "use client";
import { Card } from "flowbite-react"; import { Button, ButtonGroup, Card } from "flowbite-react";
import Link from "next/link"; import { ProfileActivityCollections } from "./Profile.ActivityCollections";
import { numberDeclension } from "#/api/utils"; import { useEffect, useState } from "react";
import { ProfileActivityFriends } from "./Profile.ActivityFriends";
import { ProfileActivityComment } from "./Profile.ActivityComment";
export function ProfileActivity(props: { export function ProfileActivity(props: {
profile_id: number; profile_id: number;
commentCount: number; commentCount: number;
videoCount: number; commentPreview: any;
collectionCount: number; collectionCount: number;
collectionPreview: any;
friendsCount: number; friendsCount: number;
friendsPreview: any;
token: string;
isMyProfile: boolean;
}) { }) {
const [tab, setTab] = useState<"collections" | "comments" | "friends">(
"collections"
);
const [collections, setCollections] = useState<Record<number, any>>({});
function _setCollection(array: any[]) {
if (array && array.length == 0) {
return;
}
let _coll = array.filter((col) => {
if (typeof col == "number") {
return false;
}
return true;
});
_coll.map((col) => {
setCollections((prev) => {
return {
...prev,
[col.id]: col,
};
});
if (
col.creator.collections_preview &&
col.creator.collections_preview.length > 0
) {
_setCollection(col.creator.collections_preview || []);
}
});
}
useEffect(() => {
_setCollection(props.collectionPreview || []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.collectionPreview]);
return ( return (
<Card className="h-fit"> <Card className="overflow-hidden h-fit">
<h1 className="text-2xl font-bold">Активность</h1> <h1 className="text-2xl font-bold">Активность</h1>
<div className="flex items-center gap-4 text-lg"> <ButtonGroup>
<div> <Button
<p> color={tab == "collections" ? "blue" : "light"}
{props.commentCount}{" "} onClick={() => setTab("collections")}
{numberDeclension( >
props.commentCount, <div className="flex flex-col gap-1 sm:flex-row sm:items-center">
"комментарий", <p>Коллекции</p>
"комментария", <p>( {props.collectionCount} )</p>
"комментариев" </div>
)} </Button>
</p> <Button
<p className="mt-2">{props.videoCount} видео</p> color={tab == "comments" ? "blue" : "light"}
</div> onClick={() => setTab("comments")}
<div> >
<Link href={`/profile/${props.profile_id}/collections`}> <div className="flex flex-col gap-1 sm:flex-row sm:items-center">
<p className="border-b-2 border-gray-300 border-solid dark:border-gray-400 hover:border-gray-500 dark:hover:border-gray-200"> <p>Комментарии</p>
{props.collectionCount}{" "} <p>( {props.commentCount} )</p>
{numberDeclension( </div>
props.commentCount, </Button>
"коллекция", <Button
"коллекции", color={tab == "friends" ? "blue" : "light"}
"коллекций" onClick={() => setTab("friends")}
)} >
</p> <div className="flex flex-col gap-1 sm:flex-row sm:items-center">
</Link> <p>Друзья</p>
<p className="mt-2"> <p>( {props.friendsCount} )</p>
{props.friendsCount}{" "} </div>
{numberDeclension(props.commentCount, "друзей", "друга", "друзей")} </Button>
</p> </ButtonGroup>
</div>
</div> {tab == "collections" && (
<ProfileActivityCollections
content={Object.values(collections) || []}
profile_id={props.profile_id}
/>
)}
{tab == "comments" && (
<ProfileActivityComment
content={props.commentPreview || []}
profile_id={props.profile_id}
/>
)}
{tab == "friends" && (
<ProfileActivityFriends
token={props.token}
content={props.friendsPreview || []}
isMyProfile={props.isMyProfile}
profile_id={props.profile_id}
/>
)}
</Card> </Card>
); );
} }

View file

@ -0,0 +1,64 @@
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/mousewheel";
import "swiper/css/scrollbar";
import { Navigation, Mousewheel, Scrollbar } from "swiper/modules";
import { CollectionLink } from "../CollectionLink/CollectionLink";
import Link from "next/link";
export const ProfileActivityCollections = (props: {
content: any;
profile_id: number;
}) => {
return (
<div className="max-w-full">
<Swiper
modules={[Navigation, Mousewheel, Scrollbar]}
spaceBetween={8}
slidesPerView={"auto"}
direction={"horizontal"}
mousewheel={{
enabled: true,
sensitivity: 4,
}}
scrollbar={{
enabled: true,
draggable: true,
}}
allowTouchMove={true}
style={
{
"--swiper-scrollbar-bottom": "0",
} as React.CSSProperties
}
>
{props.content &&
props.content.length > 0 &&
props.content.map((collection) => {
return (
<SwiperSlide
key={`col-prev-${collection.id}`}
style={{ width: "fit-content" }}
>
<div className="w-[350px] xl:w-[500px] aspect-video">
<CollectionLink {...collection} />
</div>
</SwiperSlide>
);
})}
{props.content && props.content.length > 0 ?
<SwiperSlide style={{ width: "fit-content" }}>
<Link href={`/profile/${props.profile_id}/collections`}>
<div className="w-[350px] xl:w-[500px] flex flex-col items-center justify-center gap-2 text-black transition-colors bg-gray-100 border hover:bg-gray-200 border-gray-50 hover:border-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-500 aspect-video group dark:bg-gray-600 dark:text-white">
<span className="w-8 h-8 iconify mdi--arrow-right dark:fill-white"></span>
<p>Все коллекции</p>
</div>
</Link>
</SwiperSlide>
: <p className="text-lg">У пользователя нет коллекций</p>}
</Swiper>
</div>
);
};

View file

@ -0,0 +1,92 @@
import Link from "next/link";
import Image from "next/image";
import { sinceUnixDate, unixToDate } from "#/api/utils";
export const ProfileActivityComment = (props: {
content: any;
profile_id: number;
}) => {
return (
<>
{props.content && props.content.length > 0 ?
props.content.map((comment) => {
let isHidden = comment.isSpoiler || comment.likes_count < -5 || false;
return (
<article
className="px-4 py-2 text-sm bg-gray-100 rounded-lg sm:text-base dark:bg-gray-900"
key={`comment-${comment.id}`}
>
<footer className="flex items-center justify-between mb-2">
<div className="flex flex-col items-start gap-1 sm:items-center sm:flex-row">
<Link
href={`/profile/${comment.profile.id}`}
className="inline-flex items-center mr-3 text-sm font-semibold text-gray-900 dark:text-white hover:underline"
>
<Image
className="w-6 h-6 mr-2 rounded-full"
width={24}
height={24}
src={comment.profile.avatar}
alt=""
/>
{comment.profile.login}
</Link>
<p className="text-sm text-gray-600 dark:text-gray-400">
<time
dateTime={comment.timestamp.toString()}
title={unixToDate(comment.timestamp, "full")}
>
{sinceUnixDate(comment.timestamp)}
</time>
</p>
<p
className={`text-sm font-medium border px-1 py-0.5 rounded-md text-center ml-4 min-w-8 ${
comment.likes_count > 0 ?
"text-green-500 dark:text-green-400 border-green-500 dark:border-green-400"
: comment.likes_count < 0 ?
"text-red-500 dark:text-red-400 border-red-500 dark:border-red-400"
: "text-gray-500 dark:text-gray-400 border-gray-500 dark:border-gray-400"
}`}
>
{comment.likes_count}
</p>
</div>
</footer>
<div className="relative flex flex-col py-2">
{comment.release && typeof comment.release != "number" && (
<Link href={`/release/${comment.release.id}`}>
<p className="text-gray-900 whitespace-pre-wrap dark:text-gray-500">
{!comment.isDeleted ?
`К релизу: ${comment.release.title_ru || comment.release.title_alt || comment.release.title_original} (${comment.release.year || "?"}) >>`
: ""}
</p>
</Link>
)}
<p className="text-gray-800 whitespace-pre-wrap dark:text-gray-400">
{!comment.isDeleted ?
comment.message
: "Комментарий был удалён."}
</p>
{isHidden && (
<button
className="absolute top-0 bottom-0 left-0 right-0"
onClick={() => isHidden == false}
>
<div className="min-w-full min-h-full px-2 py-1.5 rounded-md bg-black text-white bg-opacity-50 backdrop-blur-[8px] flex flex-col justify-center items-center">
<p>
{comment.likes_count < -5 ?
"У комментария слишком низкий рейтинг."
: "Данный комментарий может содержать спойлер."}
</p>
<p className="font-bold">Нажмите, чтобы прочитать</p>
</div>
</button>
)}
</div>
</article>
);
})
: <p className="text-lg">Пользователь не оставлял комментарии</p>}
</>
);
};

View file

@ -0,0 +1,87 @@
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/mousewheel";
import "swiper/css/scrollbar";
import { Navigation, Mousewheel, Scrollbar } from "swiper/modules";
import Link from "next/link";
import { Avatar, Button } from "flowbite-react";
import { useState } from "react";
import { ProfileFriendModal } from "./Profile.FriendsModal";
export const ProfileActivityFriends = (props: {
content: any;
token: string;
isMyProfile: boolean;
profile_id: number;
}) => {
const [isFriendModalOpen, setIsFriendModalOpen] = useState(false);
return (
<>
<div className="max-w-full">
<Swiper
modules={[Navigation, Mousewheel, Scrollbar]}
spaceBetween={8}
slidesPerView={"auto"}
direction={"horizontal"}
mousewheel={{
enabled: true,
sensitivity: 4,
}}
scrollbar={{
enabled: true,
draggable: true,
}}
allowTouchMove={true}
style={
{
"--swiper-scrollbar-bottom": "0",
} as React.CSSProperties
}
>
{props.content &&
props.content.length > 0 &&
props.content.map((profile) => {
return (
<SwiperSlide
key={`friend-prev-${profile.id}`}
style={{ width: "fit-content" }}
className="px-2 py-4"
>
<Link href={`/profile/${profile.id}`}>
<div className="flex items-center gap-2">
<Avatar
img={profile.avatar}
size="md"
rounded={true}
bordered={true}
color={profile.is_online ? "success" : "light"}
className="flex-shrink-0"
/>
<p className="text-lg">{profile.login}</p>
</div>
</Link>
</SwiperSlide>
);
})}
{(props.content && props.content.length > 0) || props.isMyProfile ?
<SwiperSlide style={{ width: "fit-content" }} className="px-2 py-4">
<Button onClick={() => setIsFriendModalOpen(true)}>
<p className="text-lg">Все друзья {props.isMyProfile ? "и заявки" : ""}</p>
<span className="w-8 h-8 iconify mdi--arrow-right dark:fill-white"></span>
</Button>
</SwiperSlide>
: <p className="text-lg">У пользователя нет друзей</p>}
</Swiper>
</div>
<ProfileFriendModal
isOpen={isFriendModalOpen}
setIsOpen={setIsFriendModalOpen}
token={props.token}
isMyProfile={props.isMyProfile}
profile_id={props.profile_id}
></ProfileFriendModal>
</>
);
};

View file

@ -0,0 +1,177 @@
import { ENDPOINTS } from "#/api/config";
import { tryCatchAPI, unixToDate, useSWRfetcher } from "#/api/utils";
import { Avatar, Button, Modal, ModalHeader, useThemeMode } from "flowbite-react";
import { useCallback, useEffect, useState } from "react";
import useSWRInfinite from "swr/infinite";
import { Spinner } from "../Spinner/Spinner";
import { toast } from "react-toastify";
export const ProfileBlockedUsersModal = (props: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
token: string;
profile_id: number;
}) => {
const [currentRef, setCurrentRef] = useState<any>(null);
const theme = useThemeMode();
const [actionsDisabled, setActionsDisabled] = useState(false);
const [unblockedUsers, setUnblockedUsers] = useState([]);
const modalRef = useCallback((ref) => {
setCurrentRef(ref);
}, []);
const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.content.length) return null;
let url = `${ENDPOINTS.user.blocklist}/all/${pageIndex}?token=${props.token}`;
return url;
};
const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey,
useSWRfetcher,
{ initialSize: 2 }
);
async function _addToBlocklist(profile_id) {
setActionsDisabled(true);
const tid = toast.loading(
unblockedUsers.includes(profile_id) ?
"Блокируем пользователя..."
: "Разблокируем пользователя...",
{
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
}
);
let url = `${ENDPOINTS.user.blocklist}`;
unblockedUsers.includes(profile_id) ?
(url += "/add/")
: (url += "/remove/");
url += `${profile_id}?token=${props.token}`;
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render:
unblockedUsers.includes(profile_id) ?
"Ошибка блокировки"
: "Ошибка разблокировки",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setActionsDisabled(false);
return;
}
toast.update(tid, {
render:
unblockedUsers.includes(profile_id) ?
"Пользователь заблокирован"
: "Пользователь разблокирован",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
if (unblockedUsers.includes(profile_id)) {
setUnblockedUsers((prev) => {
return prev.filter((item) => item !== profile_id);
});
} else {
setUnblockedUsers((prev) => {
return [...prev, profile_id];
});
}
setActionsDisabled(false);
}
const [content, setContent] = useState([]);
useEffect(() => {
if (data) {
let allReleases = [];
for (let i = 0; i < data.length; i++) {
allReleases.push(...data[i].content);
}
setContent(allReleases);
}
}, [data]);
const [scrollPosition, setScrollPosition] = useState(0);
function handleScroll() {
const height = currentRef.scrollHeight - currentRef.clientHeight;
const windowScroll = currentRef.scrollTop;
const scrolled = (windowScroll / height) * 100;
setScrollPosition(Math.floor(scrolled));
}
useEffect(() => {
if (scrollPosition >= 95 && scrollPosition <= 96) {
setSize(size + 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollPosition]);
return (
<>
<Modal
dismissible
show={props.isOpen}
onClose={() => props.setIsOpen(false)}
size={"7xl"}
>
<ModalHeader>Заблокированные пользователи</ModalHeader>
<div
className="flex flex-col gap-2 p-4 overflow-y-auto"
onScroll={handleScroll}
ref={modalRef}
>
{content && content.length > 0 ?
content.map((user) => {
return (
<div className="flex items-center justify-between gap-2" key={`blockeduser-${user.id}`}>
<div className="flex gap-2">
<Avatar
alt=""
img={user.avatar}
rounded={true}
size={"md"}
bordered={true}
color={user.is_online ? "success" : "light"}
className="flex-shrink-0"
/>
<div className="flex flex-col gap-1">
<p className="text-lg font-semibold">{user.login}</p>
<p>Заблокирован: {unixToDate(user.added_date, "full")}</p>
</div>
</div>
<Button
color={!unblockedUsers.includes(user.id) ? "blue" : "red"}
onClick={() => _addToBlocklist(user.id)}
disabled={actionsDisabled}
className="flex-grow-0 h-fit"
>
{!unblockedUsers.includes(user.id) ?
"Разблокировать"
: "Заблокировать"}
</Button>
</div>
);
})
: "Нет заблокированных пользователей"}
{isLoading && <Spinner />}
</div>
</Modal>
</>
);
};

View file

@ -1,11 +1,13 @@
"use client"; "use client";
import { Button, Modal, Textarea } from "flowbite-react"; import { Button, Modal, ModalBody, ModalFooter, ModalHeader, Textarea, useThemeMode } from "flowbite-react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { Spinner } from "../Spinner/Spinner"; import { Spinner } from "../Spinner/Spinner";
import { unixToDate } from "#/api/utils"; import { unixToDate } from "#/api/utils";
import { toast } from "react-toastify";
import { tryCatchAPI } from "#/api/utils";
import { useUserStore } from "#/store/auth"; import { useUserStore } from "#/store/auth";
export const ProfileEditLoginModal = (props: { export const ProfileEditLoginModal = (props: {
@ -29,21 +31,33 @@ export const ProfileEditLoginModal = (props: {
const [_loginLength, _setLoginLength] = useState(0); const [_loginLength, _setLoginLength] = useState(0);
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const userStore = useUserStore(); const userStore = useUserStore();
const theme = useThemeMode();
useEffect(() => { useEffect(() => {
setLoading(true); async function _fetchLogin() {
fetch(`${ENDPOINTS.user.settings.login.info}?token=${props.token}`) setLoading(true);
.then((res) => {
if (res.ok) { const { data, error } = await tryCatchAPI(
return res.json(); fetch(`${ENDPOINTS.user.settings.login.info}?token=${props.token}`)
} );
})
.then((data) => { if (error) {
_setLoginData(data); toast.error("Ошибка получения текущего никнейма", {
_setLogin(data.login); autoClose: 2500,
_setLoginLength(data.login.length); isLoading: false,
closeOnClick: true,
draggable: true,
});
setLoading(false); setLoading(false);
}); props.setIsOpen(false);
return;
}
_setLoginData(data);
_setLogin(data.login);
_setLoginLength(data.login.length);
setLoading(false);
}
_fetchLogin();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isOpen]); }, [props.isOpen]);
@ -52,43 +66,69 @@ export const ProfileEditLoginModal = (props: {
_setLoginLength(e.target.value.length); _setLoginLength(e.target.value.length);
} }
function _setLoginSetting() { async function _setLoginSetting() {
setSending(true);
if (!_login || _login == "") { if (!_login || _login == "") {
alert("Никнейм не может быть пустым"); toast.error("Никнейм не может быть пустым", {
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
return; return;
} }
fetch(
`${ENDPOINTS.user.settings.login.change}?login=${encodeURIComponent(
_login
)}&token=${props.token}`
)
.then((res) => {
if (res.ok) {
return res.json();
} else {
new Error("failed to send data");
}
})
.then((data) => {
if (data.code == 3) {
alert("Данный никнейм уже существует, попробуйте другой");
setSending(false);
return;
}
mutate( setSending(true);
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}`
); const tid = toast.loading("Обновляем никнейм...", {
userStore.checkAuth(); position: "bottom-center",
props.setLogin(_login); hideProgressBar: true,
setSending(false); closeOnClick: false,
props.setIsOpen(false); pauseOnHover: false,
}) draggable: false,
.catch((err) => { theme: theme.mode == "light" ? "light" : "dark",
console.log(err); });
setSending(false);
const { data, error } = await tryCatchAPI(
fetch(
`${ENDPOINTS.user.settings.login.change}?login=${encodeURIComponent(
_login
)}&token=${props.token}`
)
);
if (error) {
let message = `Ошибка обновления никнейма: ${error.code}`;
if (error.code == 3) {
message = "Данный никнейм уже существует, попробуйте другой";
}
toast.update(tid, {
render: message,
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
}); });
setSending(false);
return;
}
toast.update(tid, {
render: "Никнейм обновлён",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
mutate(
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}`
);
userStore.checkAuth();
props.setLogin(_login);
setSending(false);
props.setIsOpen(false);
} }
return ( return (
@ -98,15 +138,14 @@ export const ProfileEditLoginModal = (props: {
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
size={"4xl"} size={"4xl"}
> >
<Modal.Header>Изменить никнейм</Modal.Header> <ModalHeader>Изменить никнейм</ModalHeader>
<Modal.Body> <ModalBody>
{loading ? ( {loading ?
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Spinner /> <Spinner />
</div> </div>
) : ( : <>
<> {!_loginData.is_change_available ?
{!_loginData.is_change_available ? (
<> <>
<p>Вы недавно изменили никнейм</p> <p>Вы недавно изменили никнейм</p>
<p> <p>
@ -116,8 +155,7 @@ export const ProfileEditLoginModal = (props: {
</span> </span>
</p> </p>
</> </>
) : ( : <>
<>
<Textarea <Textarea
disabled={sending} disabled={sending}
rows={1} rows={1}
@ -132,11 +170,11 @@ export const ProfileEditLoginModal = (props: {
{_loginLength}/20 {_loginLength}/20
</p> </p>
</> </>
)} }
</> </>
)} }
</Modal.Body> </ModalBody>
<Modal.Footer> <ModalFooter>
{_loginData.is_change_available && ( {_loginData.is_change_available && (
<Button <Button
color="blue" color="blue"
@ -146,10 +184,14 @@ export const ProfileEditLoginModal = (props: {
Сохранить Сохранить
</Button> </Button>
)} )}
<Button color="red" onClick={() => props.setIsOpen(false)}> <Button
color="red"
onClick={() => props.setIsOpen(false)}
disabled={sending || loading}
>
Отмена Отмена
</Button> </Button>
</Modal.Footer> </ModalFooter>
</Modal> </Modal>
); );
}; };

View file

@ -1,11 +1,11 @@
"use client"; "use client";
import { FileInput, Label, Modal } from "flowbite-react"; import { FileInput, Label, Modal, ModalBody, ModalHeader, useThemeMode } from "flowbite-react";
import { Spinner } from "../Spinner/Spinner"; import { Spinner } from "../Spinner/Spinner";
import useSWR from "swr"; import useSWR from "swr";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { b64toBlob, unixToDate } from "#/api/utils"; import { b64toBlob, tryCatchAPI, unixToDate, useSWRfetcher } from "#/api/utils";
import { ProfileEditPrivacyModal } from "./Profile.EditPrivacyModal"; import { ProfileEditPrivacyModal } from "./Profile.EditPrivacyModal";
import { ProfileEditStatusModal } from "./Profile.EditStatusModal"; import { ProfileEditStatusModal } from "./Profile.EditStatusModal";
import { ProfileEditSocialModal } from "./Profile.EditSocialModal"; import { ProfileEditSocialModal } from "./Profile.EditSocialModal";
@ -13,20 +13,8 @@ import { CropModal } from "../CropModal/CropModal";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { useUserStore } from "#/store/auth"; import { useUserStore } from "#/store/auth";
import { ProfileEditLoginModal } from "./Profile.EditLoginModal"; import { ProfileEditLoginModal } from "./Profile.EditLoginModal";
import { toast } from "react-toastify";
const fetcher = async (url: string) => { import { ProfileBlockedUsersModal } from "./Profile.BlockedUsersModal";
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
export const ProfileEditModal = (props: { export const ProfileEditModal = (props: {
isOpen: boolean; isOpen: boolean;
@ -37,10 +25,8 @@ export const ProfileEditModal = (props: {
const [privacyModalOpen, setPrivacyModalOpen] = useState(false); const [privacyModalOpen, setPrivacyModalOpen] = useState(false);
const [statusModalOpen, setStatusModalOpen] = useState(false); const [statusModalOpen, setStatusModalOpen] = useState(false);
const [socialModalOpen, setSocialModalOpen] = useState(false); const [socialModalOpen, setSocialModalOpen] = useState(false);
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false);
const [avatarUri, setAvatarUri] = useState(null); const [blockedModalOpen, setBlockedModalOpen] = useState(false);
const [tempAvatarUri, setTempAvatarUri] = useState(null);
const [privacyModalSetting, setPrivacyModalSetting] = useState("none"); const [privacyModalSetting, setPrivacyModalSetting] = useState("none");
const [privacySettings, setPrivacySettings] = useState({ const [privacySettings, setPrivacySettings] = useState({
privacy_stats: 9, privacy_stats: 9,
@ -56,6 +42,14 @@ export const ProfileEditModal = (props: {
const [login, setLogin] = useState(""); const [login, setLogin] = useState("");
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const userStore = useUserStore(); const userStore = useUserStore();
const theme = useThemeMode();
const [avatarModalProps, setAvatarModalProps] = useState({
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
});
const privacy_stat_act_social_text = { const privacy_stat_act_social_text = {
0: "Все пользователи", 0: "Все пользователи",
@ -70,7 +64,11 @@ export const ProfileEditModal = (props: {
}; };
function useFetchInfo(url: string) { function useFetchInfo(url: string) {
const { data, isLoading, error } = useSWR(url, fetcher); if (!props.token) {
url = "";
}
const { data, isLoading, error } = useSWR(url, useSWRfetcher);
return [data, isLoading, error]; return [data, isLoading, error];
} }
@ -81,15 +79,17 @@ export const ProfileEditModal = (props: {
`${ENDPOINTS.user.settings.login.info}?token=${props.token}` `${ENDPOINTS.user.settings.login.info}?token=${props.token}`
); );
const handleFileRead = (e, fileReader) => { const handleAvatarPreview = (e: any) => {
const content = fileReader.result; const file = e.target.files[0];
setTempAvatarUri(content);
};
const handleFilePreview = (file) => {
const fileReader = new FileReader(); const fileReader = new FileReader();
fileReader.onloadend = (e) => { fileReader.onloadend = () => {
handleFileRead(e, fileReader); const content = fileReader.result;
setAvatarModalProps({
...avatarModalProps,
isOpen: true,
selectedImage: content,
});
e.target.value = "";
}; };
fileReader.readAsDataURL(file); fileReader.readAsDataURL(file);
}; };
@ -117,8 +117,8 @@ export const ProfileEditModal = (props: {
}, [loginData]); }, [loginData]);
useEffect(() => { useEffect(() => {
if (avatarUri) { async function _uploadAvatar() {
let block = avatarUri.split(";"); let block = avatarModalProps.croppedImage.split(";");
let contentType = block[0].split(":")[1]; let contentType = block[0].split(":")[1];
let realData = block[1].split(",")[1]; let realData = block[1].split(",")[1];
const blob = b64toBlob(realData, contentType); const blob = b64toBlob(realData, contentType);
@ -126,23 +126,74 @@ export const ProfileEditModal = (props: {
const formData = new FormData(); const formData = new FormData();
formData.append("image", blob, "cropped.jpg"); formData.append("image", blob, "cropped.jpg");
formData.append("name", "image"); formData.append("name", "image");
const uploadRes = fetch(
`${ENDPOINTS.user.settings.avatar}?token=${props.token}`, setAvatarModalProps(
{ (state) => (state = { ...state, isActionsDisabled: true })
);
const tid = toast.loading("Обновление аватара...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
const { data, error } = await tryCatchAPI(
fetch(`${ENDPOINTS.user.settings.avatar}?token=${props.token}`, {
method: "POST", method: "POST",
body: formData, body: formData,
} })
).then((res) => { );
if (res.ok) {
mutate( if (error) {
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}` toast.update(tid, {
); render: "Ошибка обновления аватара",
userStore.checkAuth(); type: "error",
} autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setAvatarModalProps(
(state) => (state = { ...state, isActionsDisabled: false })
);
return;
}
toast.update(tid, {
render: "Аватар обновлён",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
}); });
setAvatarModalProps(
(state) =>
(state = {
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
})
);
mutate(
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}`
);
userStore.checkAuth();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [avatarUri]); if (avatarModalProps.croppedImage) {
_uploadAvatar();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [avatarModalProps.croppedImage]);
if (!prefData || !loginData || prefError || loginError) {
return <></>;
}
return ( return (
<> <>
@ -151,12 +202,11 @@ export const ProfileEditModal = (props: {
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
size={"7xl"} size={"7xl"}
> >
<Modal.Header>Редактирование профиля</Modal.Header> <ModalHeader>Редактирование профиля</ModalHeader>
<Modal.Body> <ModalBody>
{prefLoading ? ( {prefLoading ?
<Spinner /> <Spinner />
) : ( : <div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 pb-4 border-b-2 border-gray-300 border-solid"> <div className="flex flex-col gap-2 pb-4 border-b-2 border-gray-300 border-solid">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -174,19 +224,18 @@ export const ProfileEditModal = (props: {
className="hidden" className="hidden"
accept="image/jpg, image/jpeg, image/png" accept="image/jpg, image/jpeg, image/png"
onChange={(e) => { onChange={(e) => {
handleFilePreview(e.target.files[0]); handleAvatarPreview(e);
setAvatarModalOpen(true);
}} }}
/> />
<div> <div>
<p className="text-lg">Изменить фото профиля</p> <p className="text-lg">Изменить фото профиля</p>
<p className="text-base text-gray-500 dark:text-gray-400"> <p className="text-base text-gray-500 dark:text-gray-400">
{prefData.is_change_avatar_banned {prefData.is_change_avatar_banned ?
? `Заблокировано до ${unixToDate( `Заблокировано до ${unixToDate(
prefData.ban_change_avatar_expires, prefData.ban_change_avatar_expires,
"full" "full"
)}` )}`
: "Загрузить с устройства"} : "Загрузить с устройства"}
</p> </p>
</div> </div>
</Label> </Label>
@ -211,12 +260,12 @@ export const ProfileEditModal = (props: {
> >
<p className="text-lg">Изменить никнейм</p> <p className="text-lg">Изменить никнейм</p>
<p className="text-base text-gray-500 dark:text-gray-400"> <p className="text-base text-gray-500 dark:text-gray-400">
{prefData.is_change_login_banned {prefData.is_change_login_banned ?
? `Заблокировано до ${unixToDate( `Заблокировано до ${unixToDate(
prefData.ban_change_login_expires, prefData.ban_change_login_expires,
"full" "full"
)}` )}`
: login} : login}
</p> </p>
</button> </button>
<button <button
@ -309,13 +358,18 @@ export const ProfileEditModal = (props: {
} }
</p> </p>
</button> </button>
{/* <button className="p-2 text-left rounded-md hover:bg-gray-100 dark:hover:bg-gray-900"> <button
className="p-2 text-left rounded-md hover:bg-gray-100 dark:hover:bg-gray-900"
onClick={() => {
setBlockedModalOpen(true);
}}
>
<p className="text-lg">Блоклист</p> <p className="text-lg">Блоклист</p>
<p className="text-base text-gray-500 dark:text-gray-400"> <p className="text-base text-gray-500 dark:text-gray-400">
Список пользователей, которым запрещён доступ к вашей Список пользователей, которым запрещён доступ к вашей
странице странице
</p> </p>
</button> */} </button>
</div> </div>
<div> <div>
<div className="flex flex-col"> <div className="flex flex-col">
@ -330,9 +384,9 @@ export const ProfileEditModal = (props: {
<div className="p-2 mt-2 cursor-not-allowed"> <div className="p-2 mt-2 cursor-not-allowed">
<p className="text-lg">Связанные аккаунты</p> <p className="text-lg">Связанные аккаунты</p>
<p className="text-base text-gray-500 dark:text-gray-400"> <p className="text-base text-gray-500 dark:text-gray-400">
{socialBounds.vk || socialBounds.google {socialBounds.vk || socialBounds.google ?
? "Аккаунт привязан к:" "Аккаунт привязан к:"
: "не привязан к сервисам"}{" "} : "не привязан к сервисам"}{" "}
{socialBounds.vk && "ВК"} {socialBounds.vk && "ВК"}
{socialBounds.vk && socialBounds.google && ", "} {socialBounds.vk && socialBounds.google && ", "}
{socialBounds.google && "Google"} {socialBounds.google && "Google"}
@ -340,51 +394,59 @@ export const ProfileEditModal = (props: {
</div> </div>
</div> </div>
</div> </div>
)} }
</Modal.Body> </ModalBody>
</Modal> </Modal>
<ProfileEditPrivacyModal {props.token ?
isOpen={privacyModalOpen} <>
setIsOpen={setPrivacyModalOpen} <ProfileEditPrivacyModal
token={props.token} isOpen={privacyModalOpen}
setting={privacyModalSetting} setIsOpen={setPrivacyModalOpen}
privacySettings={privacySettings} token={props.token}
setPrivacySettings={setPrivacySettings} setting={privacyModalSetting}
/> privacySettings={privacySettings}
<ProfileEditStatusModal setPrivacySettings={setPrivacySettings}
isOpen={statusModalOpen} />
setIsOpen={setStatusModalOpen} <ProfileEditStatusModal
token={props.token} isOpen={statusModalOpen}
status={status} setIsOpen={setStatusModalOpen}
setStatus={setStatus} token={props.token}
profile_id={props.profile_id} status={status}
/> setStatus={setStatus}
<ProfileEditSocialModal profile_id={props.profile_id}
isOpen={socialModalOpen} />
setIsOpen={setSocialModalOpen} <ProfileEditSocialModal
token={props.token} isOpen={socialModalOpen}
profile_id={props.profile_id} setIsOpen={setSocialModalOpen}
/> token={props.token}
<CropModal profile_id={props.profile_id}
src={tempAvatarUri} />
setSrc={setAvatarUri} <CropModal
setTempSrc={setTempAvatarUri} {...avatarModalProps}
aspectRatio={1 / 1} cropParams={{
guides={true} aspectRatio: 1 / 1,
quality={100} forceAspect: true,
isOpen={avatarModalOpen} guides: true,
setIsOpen={setAvatarModalOpen} width: 600,
forceAspect={true} height: 600,
width={600} }}
height={600} setCropModalProps={setAvatarModalProps}
/> />
<ProfileEditLoginModal <ProfileEditLoginModal
isOpen={loginModalOpen} isOpen={loginModalOpen}
setIsOpen={setLoginModalOpen} setIsOpen={setLoginModalOpen}
token={props.token} token={props.token}
setLogin={setLogin} setLogin={setLogin}
profile_id={props.profile_id} profile_id={props.profile_id}
/> />
<ProfileBlockedUsersModal
isOpen={blockedModalOpen}
setIsOpen={setBlockedModalOpen}
token={props.token}
profile_id={props.profile_id}
/>
</>
: ""}
</> </>
); );
}; };

View file

@ -1,8 +1,10 @@
"use client"; "use client";
import { Modal } from "flowbite-react"; import { Modal, ModalBody, ModalHeader, useThemeMode } from "flowbite-react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-toastify";
import { tryCatchAPI } from "#/api/utils";
export const ProfileEditPrivacyModal = (props: { export const ProfileEditPrivacyModal = (props: {
isOpen: boolean; isOpen: boolean;
@ -33,33 +35,60 @@ export const ProfileEditPrivacyModal = (props: {
}; };
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const theme = useThemeMode();
function _setPrivacySetting(el: any) { async function _setPrivacySetting(el: any) {
let privacySettings = structuredClone(props.privacySettings); let privacySettings = structuredClone(props.privacySettings);
setLoading(true); setLoading(true);
fetch(_endpoints[props.setting], {
method: "POST", const tid = toast.loading("Обновление настроек приватности...", {
headers: { position: "bottom-center",
"Content-Type": "application/json", hideProgressBar: true,
}, closeOnClick: false,
body: JSON.stringify({ pauseOnHover: false,
permission: el.target.value, draggable: false,
}), theme: theme.mode == "light" ? "light" : "dark",
}) });
.then((res) => {
if (res.ok) { const { data, error } = await tryCatchAPI(
setLoading(false); fetch(_endpoints[props.setting], {
privacySettings[el.target.name] = el.target.value; method: "POST",
props.setPrivacySettings(privacySettings); headers: {
props.setIsOpen(false) "Content-Type": "application/json",
} else { },
new Error("failed to send data"); body: JSON.stringify({
} permission: el.target.value,
}),
}) })
.catch((err) => { );
console.log(err);
setLoading(false); if (error) {
toast.update(tid, {
render: "Ошибка обновления настроек приватности",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
}); });
setLoading(false);
return;
}
toast.update(tid, {
render: "Настройки приватности обновлены",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setLoading(false);
privacySettings[el.target.name] = el.target.value;
props.setPrivacySettings(privacySettings);
props.setIsOpen(false);
} }
return ( return (
@ -69,12 +98,12 @@ export const ProfileEditPrivacyModal = (props: {
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
size={"4xl"} size={"4xl"}
> >
<Modal.Header>{setting_text[props.setting]}</Modal.Header> <ModalHeader>{setting_text[props.setting]}</ModalHeader>
<Modal.Body> <ModalBody>
{props.setting != "none" ? ( {props.setting != "none" ?
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{props.setting == "privacy_friend_requests" ? ( {props.setting == "privacy_friend_requests" ?
<> <>
<div className="flex items-center"> <div className="flex items-center">
<input <input
@ -113,8 +142,7 @@ export const ProfileEditPrivacyModal = (props: {
</label> </label>
</div> </div>
</> </>
) : ( : <>
<>
<div className="flex items-center"> <div className="flex items-center">
<input <input
disabled={loading} disabled={loading}
@ -170,13 +198,11 @@ export const ProfileEditPrivacyModal = (props: {
</label> </label>
</div> </div>
</> </>
)} }
</div> </div>
</> </>
) : ( : ""}
"" </ModalBody>
)}
</Modal.Body>
</Modal> </Modal>
); );
}; };

View file

@ -1,10 +1,21 @@
"use client"; "use client";
import { Button, Modal, Label, TextInput } from "flowbite-react"; import {
Button,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
TextInput,
useThemeMode,
} from "flowbite-react";
import { Spinner } from "../Spinner/Spinner"; import { Spinner } from "../Spinner/Spinner";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { toast } from "react-toastify";
import { tryCatchAPI } from "#/api/utils";
export const ProfileEditSocialModal = (props: { export const ProfileEditSocialModal = (props: {
isOpen: boolean; isOpen: boolean;
@ -22,6 +33,7 @@ export const ProfileEditSocialModal = (props: {
ttPage: "", ttPage: "",
}); });
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const theme = useThemeMode();
function _addUrl(username: string, social: string) { function _addUrl(username: string, social: string) {
if (!username) { if (!username) {
@ -52,37 +64,51 @@ export const ProfileEditSocialModal = (props: {
} }
useEffect(() => { useEffect(() => {
setLoading(true); async function _fetchSettings() {
fetch(`${ENDPOINTS.user.settings.socials.info}?token=${props.token}`) setLoading(true);
.then((res) => {
if (res.ok) {
return res.json();
}
})
.then((data) => {
setSocials({
vkPage: data.vk_page,
tgPage: data.tg_page,
discordPage: data.discord_page,
instPage: data.inst_page,
ttPage: data.tt_page,
});
setLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isOpen]);
const { data, error } = await tryCatchAPI(
fetch(`${ENDPOINTS.user.settings.socials.info}?token=${props.token}`)
);
if (error) {
toast.error("Ошибка получения соц. сетей", {
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setLoading(false);
props.setIsOpen(false);
return;
}
setSocials({
vkPage: data.vk_page,
tgPage: data.tg_page,
discordPage: data.discord_page,
instPage: data.inst_page,
ttPage: data.tt_page,
});
setLoading(false);
}
_fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isOpen]);
function handleInput(e: any) { function handleInput(e: any) {
const social = { const social = {
...socials, ...socials,
[e.target.name]: e.target.value [e.target.name]: e.target.value,
} };
setSocials(social); setSocials(social);
} }
function _setSocialSetting() { async function _setSocialSetting() {
const data = { const body = {
vkPage: _removeUrl(socials.vkPage), vkPage: _removeUrl(socials.vkPage),
tgPage: _removeUrl(socials.tgPage), tgPage: _removeUrl(socials.tgPage),
discordPage: _removeUrl(socials.discordPage), discordPage: _removeUrl(socials.discordPage),
@ -91,28 +117,53 @@ export const ProfileEditSocialModal = (props: {
}; };
setUpdating(true); setUpdating(true);
fetch(`${ENDPOINTS.user.settings.socials.edit}?token=${props.token}`, { const tid = toast.loading("Обновление соц. сетей...", {
method: "POST", position: "bottom-center",
headers: { hideProgressBar: true,
"Content-Type": "application/json", closeOnClick: false,
}, pauseOnHover: false,
body: JSON.stringify(data), draggable: false,
}) theme: theme.mode == "light" ? "light" : "dark",
.then((res) => { });
if (res.ok) {
mutate( const { data, error } = await tryCatchAPI(
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}` fetch(`${ENDPOINTS.user.settings.socials.edit}?token=${props.token}`, {
); method: "POST",
setUpdating(false); headers: {
props.setIsOpen(false); "Content-Type": "application/json",
} else { },
new Error("failed to send data"); body: JSON.stringify(body),
}
}) })
.catch((err) => { );
console.log(err);
setUpdating(false); if (error) {
toast.update(tid, {
render: "Ошибка обновления соц. сетей",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
}); });
setUpdating(false);
return;
}
toast.update(tid, {
render: "Соц. сети обновлены",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
mutate(
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}`
);
setUpdating(false);
props.setIsOpen(false);
} }
return ( return (
@ -122,21 +173,20 @@ export const ProfileEditSocialModal = (props: {
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
size={"4xl"} size={"4xl"}
> >
<Modal.Header>Соц. сети</Modal.Header> <ModalHeader>Соц. сети</ModalHeader>
<Modal.Body> <ModalBody>
<p className="p-2 text-gray-400 border-2 border-gray-200 rounded-md dark:border-gray-500 dark:text-gray-300"> <p className="p-2 text-gray-400 border-2 border-gray-200 rounded-md dark:border-gray-500 dark:text-gray-300">
Укажите ссылки на свои социальные сети, чтобы другие пользователи Укажите ссылки на свои социальные сети, чтобы другие пользователи
могли с вами связаться могли с вами связаться
</p> </p>
{loading ? ( {loading ?
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Spinner /> <Spinner />
</div> </div>
) : ( : <div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-4 py-4">
<div> <div>
<div className="block mb-2"> <div className="block mb-2">
<Label htmlFor="vk-page" value="ВКонтакте" /> <Label htmlFor="vk-page">ВКонтакте</Label>
</div> </div>
<TextInput <TextInput
id="vk-page" id="vk-page"
@ -148,7 +198,7 @@ export const ProfileEditSocialModal = (props: {
</div> </div>
<div> <div>
<div className="block mb-2"> <div className="block mb-2">
<Label htmlFor="tg-page" value="Telegram" /> <Label htmlFor="tg-page">Telegram</Label>
</div> </div>
<TextInput <TextInput
id="tg-page" id="tg-page"
@ -160,7 +210,7 @@ export const ProfileEditSocialModal = (props: {
</div> </div>
<div> <div>
<div className="block mb-2"> <div className="block mb-2">
<Label htmlFor="discord-page" value="Discord" /> <Label htmlFor="discord-page">Discord</Label>
</div> </div>
<TextInput <TextInput
id="discord-page" id="discord-page"
@ -172,7 +222,7 @@ export const ProfileEditSocialModal = (props: {
</div> </div>
<div> <div>
<div className="block mb-2"> <div className="block mb-2">
<Label htmlFor="inst-page" value="Instagram" /> <Label htmlFor="inst-page">Instagram</Label>
</div> </div>
<TextInput <TextInput
id="inst-page" id="inst-page"
@ -184,7 +234,7 @@ export const ProfileEditSocialModal = (props: {
</div> </div>
<div> <div>
<div className="block mb-2"> <div className="block mb-2">
<Label htmlFor="tt-page" value="TikTok" /> <Label htmlFor="tt-page">TikTok</Label>
</div> </div>
<TextInput <TextInput
id="tt-page" id="tt-page"
@ -195,9 +245,9 @@ export const ProfileEditSocialModal = (props: {
/> />
</div> </div>
</div> </div>
)} }
</Modal.Body> </ModalBody>
<Modal.Footer> <ModalFooter>
<Button <Button
color="blue" color="blue"
onClick={() => _setSocialSetting()} onClick={() => _setSocialSetting()}
@ -205,10 +255,14 @@ export const ProfileEditSocialModal = (props: {
> >
Сохранить Сохранить
</Button> </Button>
<Button color="red" onClick={() => props.setIsOpen(false)}> <Button
color="red"
onClick={() => props.setIsOpen(false)}
disabled={updating}
>
Отмена Отмена
</Button> </Button>
</Modal.Footer> </ModalFooter>
</Modal> </Modal>
); );
}; };

View file

@ -1,9 +1,12 @@
"use client"; "use client";
import { Button, Modal, Textarea } from "flowbite-react"; import { Button, Modal, ModalBody, ModalFooter, ModalHeader, Textarea, useThemeMode } from "flowbite-react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { toast } from "react-toastify";
import { tryCatchAPI } from "#/api/utils";
import { useUserStore } from "#/store/auth";
export const ProfileEditStatusModal = (props: { export const ProfileEditStatusModal = (props: {
isOpen: boolean; isOpen: boolean;
@ -17,6 +20,8 @@ export const ProfileEditStatusModal = (props: {
const [_status, _setStatus] = useState(""); const [_status, _setStatus] = useState("");
const [_stringLength, _setStringLength] = useState(0); const [_stringLength, _setStringLength] = useState(0);
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const theme = useThemeMode();
const userStore = useUserStore();
useEffect(() => { useEffect(() => {
_setStatus(props.status); _setStatus(props.status);
@ -29,33 +34,59 @@ export const ProfileEditStatusModal = (props: {
_setStringLength(e.target.value.length); _setStringLength(e.target.value.length);
} }
function _setStatusSetting() { async function _setStatusSetting() {
setLoading(true); setLoading(true);
fetch(`${ENDPOINTS.user.settings.status}?token=${props.token}`, {
method: "POST", const tid = toast.loading("Обновление статуса...", {
headers: { position: "bottom-center",
"Content-Type": "application/json", hideProgressBar: true,
}, closeOnClick: false,
body: JSON.stringify({ pauseOnHover: false,
status: _status, draggable: false,
}), theme: theme.mode == "light" ? "light" : "dark",
}) });
.then((res) => {
if (res.ok) { const { data, error } = await tryCatchAPI(
mutate( fetch(`${ENDPOINTS.user.settings.status}?token=${props.token}`, {
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}` method: "POST",
); headers: {
setLoading(false); "Content-Type": "application/json",
props.setStatus(_status); },
props.setIsOpen(false); body: JSON.stringify({
} else { status: _status,
new Error("failed to send data"); }),
}
}) })
.catch((err) => { );
console.log(err);
setLoading(false); if (error) {
toast.update(tid, {
render: "Ошибка обновления статуса",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
}); });
setLoading(false);
return;
}
toast.update(tid, {
render: "Статус обновлён",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
props.setStatus(_status);
mutate(
`${ENDPOINTS.user.profile}/${props.profile_id}?token=${props.token}`
);
userStore.checkAuth();
setLoading(false);
props.setIsOpen(false);
} }
return ( return (
@ -65,8 +96,8 @@ export const ProfileEditStatusModal = (props: {
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
size={"4xl"} size={"4xl"}
> >
<Modal.Header>Изменить статус</Modal.Header> <ModalHeader>Изменить статус</ModalHeader>
<Modal.Body> <ModalBody>
<Textarea <Textarea
disabled={loading} disabled={loading}
rows={3} rows={3}
@ -80,13 +111,19 @@ export const ProfileEditStatusModal = (props: {
<p className="text-sm text-right text-gray-500 dark:text-gray-300"> <p className="text-sm text-right text-gray-500 dark:text-gray-300">
{_stringLength}/80 {_stringLength}/80
</p> </p>
</Modal.Body> </ModalBody>
<Modal.Footer> <ModalFooter>
<Button color="blue" onClick={() => _setStatusSetting()} disabled={loading}>Сохранить</Button> <Button
color="blue"
onClick={() => _setStatusSetting()}
disabled={loading}
>
Сохранить
</Button>
<Button color="red" onClick={() => props.setIsOpen(false)}> <Button color="red" onClick={() => props.setIsOpen(false)}>
Отмена Отмена
</Button> </Button>
</Modal.Footer> </ModalFooter>
</Modal> </Modal>
); );
}; };

View file

@ -0,0 +1,346 @@
import { ENDPOINTS } from "#/api/config";
import { tryCatchAPI, unixToDate, useSWRfetcher } from "#/api/utils";
import {
Avatar,
Button,
Modal,
ModalHeader,
useThemeMode,
} from "flowbite-react";
import { useCallback, useEffect, useState } from "react";
import useSWRInfinite from "swr/infinite";
import { Spinner } from "../Spinner/Spinner";
import { toast } from "react-toastify";
import useSWR, { mutate } from "swr";
import Link from "next/link";
export const ProfileFriendModal = (props: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
token: string;
isMyProfile: boolean;
profile_id: number;
}) => {
const [currentRef, setCurrentRef] = useState<any>(null);
const theme = useThemeMode();
const [actionsDisabled, setActionsDisabled] = useState(false);
// const [requestInUsers, setRequestInUsers] = useState([]);
// const [requestOutUsers, setRequestOutUsers] = useState([]);
const [friends, setFriends] = useState([]);
const modalRef = useCallback((ref) => {
setCurrentRef(ref);
}, []);
const useFetchRequests = (url: string) => {
const { data, error, isLoading } = useSWR(url, useSWRfetcher);
return [data, error, isLoading];
};
const [requestInUsersData, requestInUsersError, requestInUsersIsLoading] =
useFetchRequests(
props.isMyProfile ?
`${ENDPOINTS.user.friend.in}/last?token=${props.token}&count=8`
: ""
);
const [requestOutUsersData, requestOutUsersError, requestOutUsersIsLoading] =
useFetchRequests(
props.isMyProfile ?
`${ENDPOINTS.user.friend.out}/last?token=${props.token}&count=8`
: ""
);
async function _hideRequestIn(profile_id) {
const tid = toast.loading("Скрываем заявку...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
let url = `${ENDPOINTS.user.friend.hide}/${profile_id}?token=${props.token}`;
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render: "Ошибка скрытия заявки",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
return;
}
toast.update(tid, {
render: "Заявка скрыта",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
mutate(`${ENDPOINTS.user.friend.in}/last?token=${props.token}&count=8`);
}
async function _acceptRequestIn(profile_id) {
const tid = toast.loading("Принимаем запрос...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
let url = `${ENDPOINTS.user.friend.add}/${profile_id}?token=${props.token}`;
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render: "Ошибка приёма запроса",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
return;
}
toast.update(tid, {
render: "Запрос принят",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
mutate(`${ENDPOINTS.user.friend.in}/last?token=${props.token}&count=8`);
}
async function _cancelRequestOut(profile_id) {
const tid = toast.loading("Отменяем запрос...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
let url = `${ENDPOINTS.user.friend.remove}/${profile_id}?token=${props.token}`;
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render: "Ошибка отмена запроса",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
return;
}
toast.update(tid, {
render: "Запрос отменён",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
mutate(`${ENDPOINTS.user.friend.out}/last?token=${props.token}&count=8`);
}
const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.content.length) return null;
let url = `${ENDPOINTS.user.friend.list}/${props.profile_id}/${pageIndex}?token=${props.token}`;
return url;
};
const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey,
useSWRfetcher,
{ initialSize: 2 }
);
useEffect(() => {
if (data) {
let allFriends = [];
for (let i = 0; i < data.length; i++) {
allFriends.push(...data[i].content);
}
setFriends(allFriends);
}
}, [data]);
const [scrollPosition, setScrollPosition] = useState(0);
function handleScroll() {
const height = currentRef.scrollHeight - currentRef.clientHeight;
const windowScroll = currentRef.scrollTop;
const scrolled = (windowScroll / height) * 100;
setScrollPosition(Math.floor(scrolled));
}
useEffect(() => {
if (scrollPosition >= 95 && scrollPosition <= 96) {
setSize(size + 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollPosition]);
return (
<>
<Modal
dismissible
show={props.isOpen}
onClose={() => props.setIsOpen(false)}
size={"4xl"}
>
<ModalHeader>Друзья</ModalHeader>
<div
className="flex flex-col gap-4 p-4 overflow-y-auto"
onScroll={handleScroll}
ref={modalRef}
>
{props.isMyProfile && (
<>
<div className="flex flex-col gap-2">
<p className="text-lg font-semibold">Входящие заявки</p>
{(
requestInUsersData &&
requestInUsersData.content &&
requestInUsersData.content.length > 0
) ?
requestInUsersData.content.map((user) => {
return (
<div
className="flex items-center justify-between gap-2"
key={`friend_req_in-${user.id}`}
>
<Link href={`/profile/${user.id}`}>
<div className="flex gap-2">
<Avatar
alt=""
img={user.avatar}
rounded={true}
size={"md"}
bordered={true}
color={user.is_online ? "success" : "light"}
className="flex-shrink-0"
/>
<div className="flex flex-col gap-1">
<p className="text-lg font-semibold">
{user.login}
</p>
<p>Друзей: {user.friend_count}</p>
</div>
</div>
</Link>
<div className="flex gap-2">
<Button
color="blue"
onClick={() => _acceptRequestIn(user.id)}
>
Принять
</Button>
<Button
color="light"
onClick={() => _hideRequestIn(user.id)}
>
Скрыть
</Button>
</div>
</div>
);
})
: <p className="text-sm">Нет входящих заявок</p>}
</div>
<div className="flex flex-col gap-2">
<p className="text-lg font-semibold">Исходящие заявки</p>
{(
requestOutUsersData &&
requestOutUsersData.content &&
requestOutUsersData.content.length > 0
) ?
requestOutUsersData.content.map((user) => {
return (
<div
className="flex items-center justify-between gap-2"
key={`friend_req_out-${user.id}`}
>
<Link href={`/profile/${user.id}`}>
<div className="flex gap-2">
<Avatar
alt=""
img={user.avatar}
rounded={true}
size={"md"}
bordered={true}
color={user.is_online ? "success" : "light"}
className="flex-shrink-0"
/>
<div className="flex flex-col gap-1">
<p className="text-lg font-semibold">
{user.login}
</p>
<p>Друзей: {user.friend_count}</p>
</div>
</div>
</Link>
<div className="flex gap-2">
<Button
color="light"
onClick={() => _cancelRequestOut(user.id)}
>
Отменить
</Button>
</div>
</div>
);
})
: <p className="text-sm">Нет исходящих заявок</p>}
</div>
</>
)}
<div className="flex flex-col gap-2">
<p className="text-lg font-semibold">Все друзья</p>
{friends && friends.length > 0 ?
friends.map((user) => {
return (
<div
className="flex items-center justify-between gap-2"
key={`friend-${user.id}`}
>
<Link href={`/profile/${user.id}`}>
<div className="flex gap-2">
<Avatar
alt=""
img={user.avatar}
rounded={true}
size={"md"}
bordered={true}
color={user.is_online ? "success" : "light"}
className="flex-shrink-0"
/>
<div className="flex flex-col gap-1">
<p className="text-lg font-semibold">{user.login}</p>
<p>Друзей: {user.friend_count}</p>
</div>
</div>
</Link>
</div>
);
})
: <p className="text-sm">Нет друзей</p>}
</div>
{isLoading && <Spinner />}
</div>
</Modal>
</>
);
};

View file

@ -1,39 +1,79 @@
import { Card, Carousel, RatingStar, Rating } from "flowbite-react"; import { Card } from "flowbite-react";
import type {
FlowbiteCarouselIndicatorsTheme,
FlowbiteCarouselControlTheme,
} from "flowbite-react";
import { ReleaseLink } from "../ReleaseLink/ReleaseLink";
const CarouselIndicatorsTheme: FlowbiteCarouselIndicatorsTheme = { import { ReleaseChips } from "../ReleasePoster/Chips";
active: { import { Poster } from "../ReleasePoster/Poster";
off: "bg-gray-300/50 hover:bg-gray-400 dark:bg-gray-400/50 dark:hover:bg-gray-200", import Link from "next/link";
on: "bg-gray-600 dark:bg-gray-200",
},
base: "h-3 w-3 rounded-full",
wrapper: "absolute bottom-5 left-1/2 flex -translate-x-1/2 space-x-3",
};
const CarouselControlsTheme: FlowbiteCarouselControlTheme = { const profile_lists = {
base: "inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-600/30 group-hover:bg-gray-600/50 group-focus:outline-none group-focus:ring-4 group-focus:ring-gray-600 dark:bg-gray-400/30 dark:group-hover:bg-gray-400/60 dark:group-focus:ring-gray-400/70 sm:h-10 sm:w-10", // 0: "Не смотрю",
icon: "h-5 w-5 text-gray-600 dark:text-gray-400 sm:h-6 sm:w-6", 1: { name: "Смотрю", bg_color: "bg-green-500" },
}; 2: { name: "В планах", bg_color: "bg-purple-500" },
3: { name: "Просмотрено", bg_color: "bg-blue-500" },
const CarouselTheme = { 4: { name: "Отложено", bg_color: "bg-yellow-500" },
indicators: CarouselIndicatorsTheme, 5: { name: "Брошено", bg_color: "bg-red-500" },
control: CarouselControlsTheme,
}; };
export const ProfileReleaseHistory = (props: any) => { export const ProfileReleaseHistory = (props: any) => {
return ( return (
<Card className="h-fit"> <Card className="h-fit">
<h1 className="text-2xl font-bold">Недавно просмотренные</h1> <h1 className="text-2xl font-bold">Недавно просмотренные</h1>
<div className="max-w-[700px] min-h-[200px]"> <div className="flex flex-col gap-4">
<Carousel theme={CarouselTheme}> {props.history.map((release) => {
{props.history.map((release) => { const genres = [];
return <ReleaseLink key={`history-${release.id}`} {...release} />; const grade = release.grade ? Number(release.grade.toFixed(1)) : null;
})} const profile_list_status = release.profile_list_status || null;
</Carousel> let user_list = null;
if (profile_list_status != null || profile_list_status != 0) {
user_list = profile_lists[profile_list_status];
}
if (release.genres) {
const genres_array = release.genres.split(",");
genres_array.forEach((genre) => {
genres.push(genre.trim());
});
}
return (
<Link href={`/release/${release.id}`} key={`history-${release.id}`}>
<div className="flex gap-2">
<div className="flex-shrink-0 w-32">
<Poster image={release.image} className="h-auto" />
</div>
<div className="flex flex-col gap-1">
<ReleaseChips
{...release}
user_list={user_list}
grade={grade}
settings={{ lastWatchedHidden: false }}
/>
<div>
{genres.length > 0 &&
genres.map((genre: string, index: number) => {
return (
<span
key={`release_${props.id}_genre_${genre}_${index}`}
className="text-sm font-light dark:text-white"
>
{index > 0 && ", "}
{genre}
</span>
);
})}
</div>
{release.title_ru && (
<p className="text-lg font-bold dark:text-white">
{release.title_ru}
</p>
)}
{release.title_original && (
<p className="text-sm text-gray-600 dark:text-gray-300">
{release.title_original}
</p>
)}
</div>
</div>
</Link>
);
})}
</div> </div>
</Card> </Card>
); );

View file

@ -1,41 +1,12 @@
import { import { Button, Card, Modal, ModalHeader, Rating, RatingStar } from "flowbite-react";
Card,
Carousel, import { unixToDate, useSWRfetcher } from "#/api/utils";
RatingStar,
Rating,
Modal,
Button,
} from "flowbite-react";
import type {
FlowbiteCarouselIndicatorsTheme,
FlowbiteCarouselControlTheme,
} from "flowbite-react";
import Image from "next/image";
import { unixToDate } from "#/api/utils";
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import { Spinner } from "../Spinner/Spinner"; import { Spinner } from "../Spinner/Spinner";
import { Poster } from "../ReleasePoster/Poster";
const CarouselIndicatorsTheme: FlowbiteCarouselIndicatorsTheme = {
active: {
off: "bg-gray-300/50 hover:bg-gray-400 dark:bg-gray-400/50 dark:hover:bg-gray-200",
on: "bg-gray-600 dark:bg-gray-200",
},
base: "h-3 w-3 rounded-full",
wrapper: "absolute bottom-5 left-1/2 flex -translate-x-1/2 space-x-3",
};
const CarouselControlsTheme: FlowbiteCarouselControlTheme = {
base: "inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-600/30 group-hover:bg-gray-600/50 group-focus:outline-none group-focus:ring-4 group-focus:ring-gray-600 dark:bg-gray-400/30 dark:group-hover:bg-gray-400/60 dark:group-focus:ring-gray-400/70 sm:h-10 sm:w-10",
icon: "h-5 w-5 text-gray-600 dark:text-gray-400 sm:h-6 sm:w-6",
};
const CarouselTheme = {
indicators: CarouselIndicatorsTheme,
control: CarouselControlsTheme,
};
export const ProfileReleaseRatings = (props: any) => { export const ProfileReleaseRatings = (props: any) => {
const [modal, setModal] = useState(false); const [modal, setModal] = useState(false);
@ -47,37 +18,31 @@ export const ProfileReleaseRatings = (props: any) => {
Посмотреть все Посмотреть все
</Button> </Button>
</div> </div>
<div className="max-w-[700px] min-h-[200px]"> <div className="flex flex-col w-full gap-4">
<Carousel theme={CarouselTheme}> {props.ratings.map((release) => {
{props.ratings.map((release) => { return (
return ( <Link href={`/release/${release.id}`} key={`vote-${release.id}`}>
<Link href={`/release/${release.id}`} key={`vote-${release.id}`}> <div className="flex gap-2">
<div className="flex gap-4 xl:mx-20"> <div className="max-w-32">
<Image <Poster image={release.image} />
src={release.image}
width={100}
height={125}
alt=""
className="object-cover border-gray-200 rounded-lg shadow-md dark:border-gray-700 dark:bg-gray-800 w-[100px] h-[125px]"
/>
<div className="flex flex-col gap-1 py-4">
<h2 className="text-lg">{release.title_ru}</h2>
<Rating size="md">
<RatingStar filled={release.my_vote >= 1} />
<RatingStar filled={release.my_vote >= 2} />
<RatingStar filled={release.my_vote >= 3} />
<RatingStar filled={release.my_vote >= 4} />
<RatingStar filled={release.my_vote >= 5} />
</Rating>
<h2 className="text-gray-500 text-md dark:text-gray-400">
{unixToDate(release.voted_at, "full")}
</h2>
</div>
</div> </div>
</Link> <div className="flex flex-col gap-1">
); <h2 className="text-lg font-bold dark:text-white">{release.title_ru}</h2>
})} <Rating size="md">
</Carousel> <RatingStar filled={release.my_vote >= 1} />
<RatingStar filled={release.my_vote >= 2} />
<RatingStar filled={release.my_vote >= 3} />
<RatingStar filled={release.my_vote >= 4} />
<RatingStar filled={release.my_vote >= 5} />
</Rating>
<h2 className="text-gray-500 text-md dark:text-gray-400">
{unixToDate(release.voted_at, "full")}
</h2>
</div>
</div>
</Link>
);
})}
</div> </div>
<ProfileReleaseRatingsModal <ProfileReleaseRatingsModal
profile_id={props.profile_id} profile_id={props.profile_id}
@ -95,7 +60,6 @@ const ProfileReleaseRatingsModal = (props: {
profile_id: number; profile_id: number;
token: string | null; token: string | null;
}) => { }) => {
const [isLoadingEnd, setIsLoadingEnd] = useState(false);
const [currentRef, setCurrentRef] = useState<any>(null); const [currentRef, setCurrentRef] = useState<any>(null);
const modalRef = useCallback((ref) => { const modalRef = useCallback((ref) => {
setCurrentRef(ref); setCurrentRef(ref);
@ -110,23 +74,9 @@ const ProfileReleaseRatingsModal = (props: {
return url; return url;
}; };
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2 } { initialSize: 2 }
); );
@ -138,7 +88,6 @@ const ProfileReleaseRatingsModal = (props: {
allReleases.push(...data[i].content); allReleases.push(...data[i].content);
} }
setContent(allReleases); setContent(allReleases);
setIsLoadingEnd(true);
} }
}, [data]); }, [data]);
@ -164,14 +113,14 @@ const ProfileReleaseRatingsModal = (props: {
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
size={"4xl"} size={"4xl"}
> >
<Modal.Header>Оценки</Modal.Header> <ModalHeader>Оценки</ModalHeader>
<div <div
className="flex flex-col gap-2 p-4 overflow-y-auto" className="flex flex-col gap-2 p-4 overflow-y-auto"
onScroll={handleScroll} onScroll={handleScroll}
ref={modalRef} ref={modalRef}
> >
{!isLoadingEnd && isLoading && <Spinner />} {isLoading && <Spinner />}
{isLoadingEnd && !isLoading && content.length > 0 ? ( {content && content.length > 0 ?
content.map((release) => { content.map((release) => {
return ( return (
<Link <Link
@ -179,13 +128,9 @@ const ProfileReleaseRatingsModal = (props: {
key={`vote-modal-${release.id}`} key={`vote-modal-${release.id}`}
> >
<div className="flex gap-4 xl:mx-20"> <div className="flex gap-4 xl:mx-20">
<Image <div className="flex-shrink-0 max-w-32">
src={release.image} <Poster image={release.image} />
width={100} </div>
height={125}
alt=""
className="object-cover border-gray-200 rounded-lg shadow-md dark:border-gray-700 dark:bg-gray-800 w-[100px] h-[125px]"
/>
<div className="flex flex-col gap-1 py-2"> <div className="flex flex-col gap-1 py-2">
<h2 className="text-lg">{release.title_ru}</h2> <h2 className="text-lg">{release.title_ru}</h2>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -216,9 +161,7 @@ const ProfileReleaseRatingsModal = (props: {
</Link> </Link>
); );
}) })
) : ( : <h1>Оценок нет</h1>}
<h1>Оценок нет</h1>
)}
</div> </div>
</Modal> </Modal>
); );

View file

@ -0,0 +1,15 @@
interface UserRoleProps {
name: string;
color: string;
}
export const UserRole = ({ name, color }: UserRoleProps) => {
return (
<div
className={`text-[var(--color)] border border-[var(--color)] rounded-md`}
style={{ "--color": `#${color}` } as React.CSSProperties}
>
<p className="px-1.5 py-0.5">{name}</p>
</div>
);
};

View file

@ -0,0 +1,20 @@
interface UserSocialProps {
icon: string;
url?: string;
nickname: string;
color: string;
}
export const UserSocial = ({ nickname, icon, color }: UserSocialProps) => {
return (
<div
className={`border border-[var(--color)] rounded-md`}
style={{ "--color": `#${color}` } as React.CSSProperties}
>
<div className="flex gap-1 items-center px-1.5 py-1">
<span className={`iconify w-6 h-6 bg-[var(--color)] ${icon}`}></span>
<p>{nickname}</p>
</div>
</div>
);
};

View file

@ -1,145 +1,154 @@
"use client"; "use client";
import { Avatar, Card, Button } from "flowbite-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Chip } from "../Chip/Chip";
export const ProfileUser = (props: { import { Avatar, Card, useThemeMode } from "flowbite-react";
isOnline: boolean; import { UserRole } from "./Profile.Role";
import { UserSocial } from "./Profile.Social";
import Link from "next/link";
interface ProfileUserProps {
avatar: string; avatar: string;
login: string; login: string;
status: string; status: string;
socials: {
isPrivate: boolean;
hasSocials: boolean;
socials: {
name: string;
nickname: any;
icon: string;
urlPrefix?: string | undefined;
}[];
};
chips: {
hasChips: boolean;
isMyProfile: boolean;
isVerified: boolean;
isSponsor: boolean;
isBlocked: boolean;
roles?: {
id: number;
name: string;
color: string;
}[];
};
rating: number; rating: number;
}) => { roles: {
const router = useRouter(); id: number;
name: string;
color: string;
}[];
isMyProfile: boolean;
isSponsor: boolean;
isBlocked: boolean;
isVerified: boolean;
isOnline: boolean;
socials: {
vk: string;
tg: string;
tt: string;
inst: string;
discord: string;
};
is_social_hidden: boolean;
}
export const ProfileUser = ({
avatar,
login,
status,
rating,
roles,
isMyProfile,
isVerified,
isOnline,
isSponsor,
isBlocked,
socials,
is_social_hidden,
}: ProfileUserProps) => {
const theme = useThemeMode().mode;
return ( return (
<Card className="h-fit"> <Card>
{props.chips.hasChips && ( {(isMyProfile ||
<div className="flex gap-1 overflow-x-auto scrollbar-thin"> isVerified ||
{props.chips.isMyProfile && ( isSponsor ||
<Chip bg_color="bg-blue-500" name="Мой профиль" /> isBlocked ||
)} roles.length > 0) && (
{props.chips.isVerified && ( <div className="flex flex-wrap gap-2">
<Chip bg_color="bg-green-500" name="Верифицирован" /> {isMyProfile && <UserRole name="Мой профиль" color="3f83f8" />}
)} {isBlocked && <UserRole name="Заблокирован" color="f56565" />}
{props.chips.isSponsor && ( {isVerified && <UserRole name="Верифицирован" color="0e9f6e" />}
<Chip bg_color="bg-yellow-500" name="Спонсор Anixart" /> {isSponsor && <UserRole name="Спонсор Anixart" color="ecc94b" />}
)} {roles.map((role) => (
{props.chips.isBlocked && ( <UserRole key={role.id} name={role.name} color={role.color} />
<Chip bg_color="bg-red-500" name="Заблокирован" /> ))}
)}
{props.chips.roles &&
props.chips.roles.length > 0 &&
props.chips.roles.map((role: any) => (
<Chip
key={role.id}
bg_color={`bg-[var(--role-color)]`}
name={role.name}
style={
{
"--role-color": `#${role.color}`,
} as React.CSSProperties
}
/>
))}
</div> </div>
)} )}
<Avatar <div className="flex flex-col items-center gap-4 sm:items-start sm:flex-row">
alt="" <Avatar
img={props.avatar} alt=""
rounded={true} img={avatar}
size={"lg"} rounded={true}
className="relative flex-col items-center justify-center sm:justify-start sm:flex-row" size={"lg"}
bordered={true} bordered={true}
color={props.isOnline ? "success" : "light"} color={isOnline ? "success" : "light"}
> className="flex-shrink-0"
<div className="space-y-1 text-2xl font-medium whitespace-pre-wrap dark:text-white"> />
<div className="text-center sm:text-left"> <div className="flex flex-col gap-2">
{props.login}{" "} <p className="flex items-center gap-2 text-2xl font-semibold">
{login}
<span <span
className={`border rounded-md px-2 py-1 text-sm ${ className={`border rounded-md px-2 py-1 min-w-8 text-sm flex items-center justify-center ${
props.rating > 0 rating > 0 ?
? "border-green-500 text-green-500" "border-green-500 text-green-500"
: "border-red-500 text-red-500" : "border-red-500 text-red-500"
}`} }`}
> >
{props.rating} {rating}
</span> </span>
</div> </p>
<div className="text-sm text-gray-500 whitespace-pre-wrap sm:text-md dark:text-gray-400 "> <p className="text-sm whitespace-pre-wrap sm:text-md">{status}</p>
{props.status}
</div>
</div> </div>
</Avatar> </div>
{props.socials.hasSocials && !props.socials.isPrivate && ( {!is_social_hidden &&
<div className="flex items-center gap-1 overflow-x-auto scrollbar-thin"> (socials.vk ||
{props.socials.socials socials.tg ||
.filter((social: any) => { socials.discord ||
if (social.nickname == "") { socials.tt ||
return false; socials.inst) && (
} <div className="flex flex-wrap gap-2">
return true; {socials.vk && (
}) <Link href={`https://vk.com/${socials.vk}`} target="_blank">
.map((social: any) => { <UserSocial
if (social.name == "discord" && social.nickname != "") nickname={socials.vk}
return ( icon="fa6-brands--vk"
<Button url={`https://vk.com/${socials.vk}`}
color="light" color="4a76a8"
key={social.name} />
onClick={() => { </Link>
window.navigator.clipboard.writeText(social.nickname); )}
alert("Скопировано!"); {socials.tg && (
}} <Link href={`https://t.me/${socials.tg}`} target="_blank">
> <UserSocial
<div className="flex items-center justify-center gap-2"> nickname={socials.tg}
<span icon="fa6-brands--telegram"
className={`iconify h-4 w-4 sm:h-6 sm:w-6 ${social.icon} dark:fill-white`} url={`https://t.me/${socials.tg}`}
></span> color="2aabee"
{social.nickname} />
</div> </Link>
</Button> )}
); {socials.tt && (
return ( <Link href={`https://tiktok.com/@${socials.tt}`} target="_blank">
<Link <UserSocial
key={social.name} nickname={socials.tt}
href={`${social.urlPrefix}${social.nickname}`} icon="fa6-brands--tiktok"
target="_blank" url={`https://tiktok.com/@${socials.tt}`}
> color={theme == "light" ? "000000" : "ffffff"}
<Button color="light"> />
<div className="flex items-center justify-center gap-2"> </Link>
<span )}
className={`iconify h-4 w-4 sm:h-6 sm:w-6 ${social.icon} dark:fill-white`} {socials.inst && (
></span> <Link
{social.nickname} href={`https://instagram.com/${socials.inst}`}
</div> target="_blank"
</Button> >
</Link> <UserSocial
); nickname={socials.inst}
})} icon="fa6-brands--instagram"
</div> url={`https://instagram.com/${socials.inst}`}
)} color="c32aa3"
/>
</Link>
)}
{socials.discord && (
<UserSocial
nickname={socials.discord}
icon="fa6-brands--discord"
url={`https://discord.com/${socials.discord}`}
color="5865f2"
/>
)}
</div>
)}
</Card> </Card>
); );
}; };

View file

@ -28,6 +28,7 @@ export const ProfileWatchDynamic = (props: { watchDynamic: Array<any> }) => {
}, },
tooltip: { tooltip: {
enabled: true, enabled: true,
theme:"dark",
x: { x: {
show: false, show: false,
}, },

View file

@ -1,5 +1,5 @@
"use client"; "use client";
import { ReleaseLink } from "../ReleaseLink/ReleaseLink"; import { ReleaseLink } from "../ReleaseLink/ReleaseLinkUpdate";
import Link from "next/link"; import Link from "next/link";
import { Swiper, SwiperSlide } from 'swiper/react'; import { Swiper, SwiperSlide } from 'swiper/react';
@ -42,7 +42,7 @@ export const ReleaseCourusel = (props: {
allowTouchMove={true} allowTouchMove={true}
breakpoints={{ breakpoints={{
1800: { 1800: {
initialSlide: 1, initialSlide: 2,
centeredSlides: true centeredSlides: true
} }
}} }}
@ -52,7 +52,7 @@ export const ReleaseCourusel = (props: {
return ( return (
<SwiperSlide <SwiperSlide
key={release.id} key={release.id}
className="xl:max-w-[600px] sm:max-w-[400px] lg:aspect-video" className="h-full max-w-64 md:max-w-96 aspect-[384/538]"
> >
<ReleaseLink {...release} /> <ReleaseLink {...release} />
</SwiperSlide> </SwiperSlide>

View file

@ -2,6 +2,7 @@ import { Card, Button } from "flowbite-react";
import { useState } from "react"; import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { ReleaseInfoStreaming } from "./ReleaseInfo.LicensedPlatforms"; import { ReleaseInfoStreaming } from "./ReleaseInfo.LicensedPlatforms";
import { Poster } from "../ReleasePoster/Poster";
export const ReleaseInfoBasics = (props: { export const ReleaseInfoBasics = (props: {
release_id: number; release_id: number;
@ -13,15 +14,21 @@ export const ReleaseInfoBasics = (props: {
const [isFullDescription, setIsFullDescription] = useState(false); const [isFullDescription, setIsFullDescription] = useState(false);
return ( return (
<Card className="h-full"> <Card className="h-full row-span-2">
<div className="flex flex-col w-full h-full gap-4 lg:flex-row"> <div className="flex flex-col w-full h-full gap-4 lg:grid lg:grid-cols-[1fr_2fr] items-center lg:items-start justify-center lg:justify-start">
<Image <div className="flex flex-col gap-2">
className="w-[285px] max-h-[385px] object-cover border border-gray-200 rounded-lg shadow-md dark:border-gray-700" <div className="relative flex items-center justify-center w-full overflow-hidden rounded-lg">
src={props.image} <Poster
alt="" image={props.image}
width={285} className="z-10 sm:scale-95 lg:scale-100"
height={385} />
/> <Poster
image={props.image}
className="absolute top-0 left-0 w-full scale-125 opacity-75 blur-xl brightness-75"
/>
</div>
<ReleaseInfoStreaming release_id={props.release_id} />
</div>
<div className="flex flex-col max-w-2xl gap-2 text-sm md:text-base"> <div className="flex flex-col max-w-2xl gap-2 text-sm md:text-base">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-xl font-bold text-black md:text-2xl dark:text-white"> <p className="text-xl font-bold text-black md:text-2xl dark:text-white">
@ -54,7 +61,6 @@ export const ReleaseInfoBasics = (props: {
> >
{isFullDescription ? "Скрыть" : "Показать полностью"} {isFullDescription ? "Скрыть" : "Показать полностью"}
</Button> </Button>
<ReleaseInfoStreaming release_id={props.release_id} />
</div> </div>
</div> </div>
</Card> </Card>

View file

@ -1,4 +1,4 @@
import { Card, Table } from "flowbite-react"; import { Card, Table, TableBody, TableCell, TableRow } from "flowbite-react";
import { ReleaseInfoSearchLink } from "#/components/ReleaseInfo/ReleaseInfo.SearchLink"; import { ReleaseInfoSearchLink } from "#/components/ReleaseInfo/ReleaseInfo.SearchLink";
import { unixToDate, minutesToTime } from "#/api/utils"; import { unixToDate, minutesToTime } from "#/api/utils";
const weekDay = [ const weekDay = [
@ -28,11 +28,11 @@ export const ReleaseInfoInfo = (props: {
genres: string; genres: string;
}) => { }) => {
return ( return (
<Card className="h-full"> <Card>
<Table> <Table>
<Table.Body> <TableBody>
<Table.Row> <TableRow>
<Table.Cell className="py-0"> <TableCell className="py-0">
{props.country ? {props.country ?
props.country.toLowerCase() == "япония" ? props.country.toLowerCase() == "япония" ?
<span className="w-8 h-8 iconify-color twemoji--flag-for-japan"></span> <span className="w-8 h-8 iconify-color twemoji--flag-for-japan"></span>
@ -40,45 +40,45 @@ export const ReleaseInfoInfo = (props: {
: <span className="w-8 h-8 iconify-color twemoji--flag-for-united-nations "></span> : <span className="w-8 h-8 iconify-color twemoji--flag-for-united-nations "></span>
} }
</Table.Cell> </TableCell>
<Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> <TableCell className="font-medium text-gray-900 whitespace-nowrap dark:text-white">
{props.country && props.country} {props.country && props.country}
{(props.aired_on_date != 0 || props.year) && ", "} {(props.aired_on_date != 0 || props.year) && ", "}
{props.season && props.season != 0 ? {props.season && props.season != 0 ?
`${YearSeason[props.season]} ` `${YearSeason[props.season]} `
: ""} : ""}
{props.year && `${props.year} г.`} {props.year && `${props.year} г.`}
</Table.Cell> </TableCell>
</Table.Row> </TableRow>
<Table.Row> <TableRow>
<Table.Cell className="py-0"> <TableCell className="py-0">
<span className="w-8 h-8 iconify-color mdi--animation-play-outline dark:invert"></span> <span className="w-8 h-8 iconify-color mdi--animation-play-outline dark:invert"></span>
</Table.Cell> </TableCell>
<Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> <TableCell className="font-medium text-gray-900 whitespace-nowrap dark:text-white">
{props.episodes.released ? props.episodes.released : "?"} {props.episodes.released ? props.episodes.released : "?"}
{"/"} {"/"}
{props.episodes.total ? props.episodes.total + " эп. " : "? эп. "} {props.episodes.total ? props.episodes.total + " эп. " : "? эп. "}
{props.duration != 0 && {props.duration != 0 &&
`по ${minutesToTime(props.duration, "daysHours")}`} `по ${minutesToTime(props.duration, "daysHours")}`}
</Table.Cell> </TableCell>
</Table.Row> </TableRow>
<Table.Row> <TableRow>
<Table.Cell className="py-0"> <TableCell className="py-0">
<span className="w-8 h-8 iconify-color mdi--calendar-outline dark:invert"></span> <span className="w-8 h-8 iconify-color mdi--calendar-outline dark:invert"></span>
</Table.Cell> </TableCell>
<Table.Cell className="font-medium text-gray-900 dark:text-white"> <TableCell className="font-medium text-gray-900 dark:text-white">
{props.category} {props.category}
{", "} {", "}
{props.broadcast == 0 ? {props.broadcast == 0 ?
props.status.toLowerCase() props.status.toLowerCase()
: `выходит ${weekDay[props.broadcast]}`} : `выходит ${weekDay[props.broadcast]}`}
</Table.Cell> </TableCell>
</Table.Row> </TableRow>
<Table.Row> <TableRow>
<Table.Cell className="py-0"> <TableCell className="py-0">
<span className="w-8 h-8 iconify-color mdi--people-group-outline dark:invert"></span> <span className="w-8 h-8 iconify-color mdi--people-group-outline dark:invert"></span>
</Table.Cell> </TableCell>
<Table.Cell className="font-medium text-gray-900 dark:text-white"> <TableCell className="font-medium text-gray-900 dark:text-white">
{props.studio && ( {props.studio && (
<> <>
{"Студия: "} {"Студия: "}
@ -117,13 +117,13 @@ export const ReleaseInfoInfo = (props: {
/> />
</> </>
)} )}
</Table.Cell> </TableCell>
</Table.Row> </TableRow>
<Table.Row> <TableRow>
<Table.Cell className="py-0"> <TableCell className="py-0">
<span className="w-8 h-8 iconify-color mdi--tag-outline dark:invert"></span> <span className="w-8 h-8 iconify-color mdi--tag-outline dark:invert"></span>
</Table.Cell> </TableCell>
<Table.Cell className="font-medium text-gray-900 dark:text-white"> <TableCell className="font-medium text-gray-900 dark:text-white">
{props.genres && {props.genres &&
props.genres.split(", ").map((genre: string, index: number) => { props.genres.split(", ").map((genre: string, index: number) => {
return ( return (
@ -133,14 +133,14 @@ export const ReleaseInfoInfo = (props: {
</div> </div>
); );
})} })}
</Table.Cell> </TableCell>
</Table.Row> </TableRow>
{props.status.toLowerCase() == "анонс" && ( {props.status.toLowerCase() == "анонс" && (
<Table.Row> <TableRow>
<Table.Cell className="py-0"> <TableCell className="py-0">
<span className="w-8 h-8 iconify-color mdi--clock-outline dark:invert"></span> <span className="w-8 h-8 iconify-color mdi--clock-outline dark:invert"></span>
</Table.Cell> </TableCell>
<Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> <TableCell className="font-medium text-gray-900 whitespace-nowrap dark:text-white">
{props.aired_on_date != 0 ? {props.aired_on_date != 0 ?
unixToDate(props.aired_on_date, "full") unixToDate(props.aired_on_date, "full")
: props.year ? : props.year ?
@ -151,10 +151,10 @@ export const ReleaseInfoInfo = (props: {
{props.year && `${props.year} г.`} {props.year && `${props.year} г.`}
</> </>
: "Скоро"} : "Скоро"}
</Table.Cell> </TableCell>
</Table.Row> </TableRow>
)} )}
</Table.Body> </TableBody>
</Table> </Table>
</Card> </Card>
); );

View file

@ -14,7 +14,7 @@ export const ReleaseInfoStreaming = (props: { release_id: number }) => {
setData(await response.json()); setData(await response.json());
}; };
_getData(); _getData();
}, []); }, [props.release_id]);
return ( return (
<> <>
@ -23,17 +23,18 @@ export const ReleaseInfoStreaming = (props: { release_id: number }) => {
: !(data.content.length > 0) ? : !(data.content.length > 0) ?
"" ""
: <div> : <div>
<p className="mt-4 mb-1 text-lg">Официальные источники: </p> <div className="grid grid-flow-row-dense grid-cols-1 gap-1 2xl:grid-cols-2">
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
{data.content.map((item: any) => { {data.content.map((item: any) => {
return ( return (
<a <a
href={item.url} href={item.url}
target="_blank"
key={`platform_${item.id}`} key={`platform_${item.id}`}
className="flex items-center gap-2 px-2 py-1 transition-colors bg-gray-100 rounded-lg hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 " className="flex items-center gap-2 px-4 py-2 transition-colors bg-gray-100 rounded-lg hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 "
> >
<img src={item.icon} className="w-6 h-6 rounded-full" /> {/* eslint-disable-next-line @next/next/no-img-element */}
<p className="text-lg">{item.name}</p> <img alt="" src={item.icon} className="w-6 h-6 rounded-full" />
<p className="text-sm line-clamp-2">{item.name}</p>
</a> </a>
); );
})} })}

View file

@ -1,20 +1,26 @@
import { import {
Card,
Rating,
Flowbite,
Button, Button,
CustomFlowbiteTheme, Card,
Modal, Modal,
ModalBody,
ModalFooter,
ModalHeader,
Rating,
RatingAdvanced,
RatingStar,
RatingAdvancedTheme,
} from "flowbite-react"; } from "flowbite-react";
import { numberDeclension } from "#/api/utils"; import { numberDeclension } from "#/api/utils";
import { useState } from "react"; import { useState } from "react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
const RatingTheme: CustomFlowbiteTheme = { const CustomRatingTheme: RatingAdvancedTheme = {
ratingAdvanced: { base: "flex items-center",
progress: { label: "text-sm font-medium text-cyan-600 dark:text-cyan-500",
base: "mx-4 h-5 w-3/4 rounded bg-gray-200 dark:bg-gray-700", progress: {
}, base: "mx-4 h-5 w-3/4 rounded bg-gray-200 dark:bg-gray-700",
fill: "h-5 rounded bg-yellow-400",
label: "text-sm font-medium text-cyan-600 dark:text-cyan-500",
}, },
}; };
export const ReleaseInfoRating = (props: { export const ReleaseInfoRating = (props: {
@ -39,7 +45,7 @@ export const ReleaseInfoRating = (props: {
<Card> <Card>
<div className="flex flex-col gap-2 sm:items-center sm:flex-row"> <div className="flex flex-col gap-2 sm:items-center sm:flex-row">
<Rating> <Rating>
<Rating.Star /> <RatingStar />
<p className="ml-2 text-sm font-bold dark:text-white"> <p className="ml-2 text-sm font-bold dark:text-white">
{props.grade.toFixed(2)} из 5 {props.grade.toFixed(2)} из 5
</p> </p>
@ -48,7 +54,7 @@ export const ReleaseInfoRating = (props: {
<> <>
<span className="mx-1.5 h-1 w-1 rounded-full bg-gray-500 dark:bg-gray-400 hidden lg:block" /> <span className="mx-1.5 h-1 w-1 rounded-full bg-gray-500 dark:bg-gray-400 hidden lg:block" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{vote ? ( {vote ?
<> <>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400"> <p className="text-sm font-medium text-gray-500 dark:text-gray-400">
ваша оценка: {vote} ваша оценка: {vote}
@ -62,8 +68,7 @@ export const ReleaseInfoRating = (props: {
изменить изменить
</Button> </Button>
</> </>
) : ( : <Button
<Button
size={"xs"} size={"xs"}
className="text-gray-500 border border-gray-600 rounded-full hover:bg-black hover:text-white hover:border-black dark:text-gray-400 dark:border-gray-500" className="text-gray-500 border border-gray-600 rounded-full hover:bg-black hover:text-white hover:border-black dark:text-gray-400 dark:border-gray-500"
color="inline" color="inline"
@ -71,7 +76,7 @@ export const ReleaseInfoRating = (props: {
> >
оценить оценить
</Button> </Button>
)} }
</div> </div>
</> </>
)} )}
@ -80,47 +85,50 @@ export const ReleaseInfoRating = (props: {
{props.votes.total}{" "} {props.votes.total}{" "}
{numberDeclension(props.votes.total, "голос", "голоса", "голосов")} {numberDeclension(props.votes.total, "голос", "голоса", "голосов")}
</p> </p>
<Flowbite theme={{ theme: RatingTheme }}> <RatingAdvanced
<Rating.Advanced theme={CustomRatingTheme}
percentFilled={Math.floor( percentFilled={Math.floor(
(props.votes["5"] / props.votes.total) * 100 (props.votes["5"] / props.votes.total) * 100
)} )}
className="mb-2" className="mb-2"
> >
5 5
</Rating.Advanced> </RatingAdvanced>
<Rating.Advanced <RatingAdvanced
percentFilled={Math.floor( theme={CustomRatingTheme}
(props.votes["4"] / props.votes.total) * 100 percentFilled={Math.floor(
)} (props.votes["4"] / props.votes.total) * 100
className="mb-2" )}
> className="mb-2"
4 >
</Rating.Advanced> 4
<Rating.Advanced </RatingAdvanced>
percentFilled={Math.floor( <RatingAdvanced
(props.votes["3"] / props.votes.total) * 100 theme={CustomRatingTheme}
)} percentFilled={Math.floor(
className="mb-2" (props.votes["3"] / props.votes.total) * 100
> )}
3 className="mb-2"
</Rating.Advanced> >
<Rating.Advanced 3
percentFilled={Math.floor( </RatingAdvanced>
(props.votes["2"] / props.votes.total) * 100 <RatingAdvanced
)} theme={CustomRatingTheme}
className="mb-2" percentFilled={Math.floor(
> (props.votes["2"] / props.votes.total) * 100
2 )}
</Rating.Advanced> className="mb-2"
<Rating.Advanced >
percentFilled={Math.floor( 2
(props.votes["1"] / props.votes.total) * 100 </RatingAdvanced>
)} <RatingAdvanced
> theme={CustomRatingTheme}
1 percentFilled={Math.floor(
</Rating.Advanced> (props.votes["1"] / props.votes.total) * 100
</Flowbite> )}
>
1
</RatingAdvanced>
</Card> </Card>
<ReleaseInfoRatingModal <ReleaseInfoRatingModal
isOpen={isRatingModalOpen} isOpen={isRatingModalOpen}
@ -178,8 +186,8 @@ const ReleaseInfoRatingModal = (props: {
show={props.isOpen} show={props.isOpen}
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
> >
<Modal.Header>Оценка</Modal.Header> <ModalHeader>Оценка</ModalHeader>
<Modal.Body> <ModalBody>
<div> <div>
<div className="block sm:hidden"> <div className="block sm:hidden">
<Rating size="md" className="justify-center"> <Rating size="md" className="justify-center">
@ -191,7 +199,7 @@ const ReleaseInfoRatingModal = (props: {
onMouseOut={() => setCurElement(0)} onMouseOut={() => setCurElement(0)}
onClick={() => setVote(element)} onClick={() => setVote(element)}
> >
<Rating.Star <RatingStar
filled={index + 1 <= curElement || index + 1 <= vote} filled={index + 1 <= curElement || index + 1 <= vote}
/> />
</Button> </Button>
@ -208,7 +216,7 @@ const ReleaseInfoRatingModal = (props: {
onMouseOut={() => setCurElement(0)} onMouseOut={() => setCurElement(0)}
onClick={() => setVote(element)} onClick={() => setVote(element)}
> >
<Rating.Star <RatingStar
filled={index + 1 <= curElement || index + 1 <= vote} filled={index + 1 <= curElement || index + 1 <= vote}
/> />
</Button> </Button>
@ -216,8 +224,8 @@ const ReleaseInfoRatingModal = (props: {
</Rating> </Rating>
</div> </div>
</div> </div>
</Modal.Body> </ModalBody>
<Modal.Footer> <ModalFooter>
<div className="flex gap-1 ml-auto"> <div className="flex gap-1 ml-auto">
<Button <Button
disabled={isSending} disabled={isSending}
@ -241,7 +249,7 @@ const ReleaseInfoRatingModal = (props: {
Оценить Оценить
</Button> </Button>
</div> </div>
</Modal.Footer> </ModalFooter>
</Modal> </Modal>
); );
}; };

View file

@ -1,15 +1,9 @@
"use client"; "use client";
import { Card, Carousel, CustomFlowbiteTheme } from "flowbite-react"; import { Card, Carousel } from "flowbite-react";
import { ReleaseLink } from "#/components/ReleaseLink/ReleaseLink"; import { ReleaseLink } from "#/components/ReleaseLink/ReleaseLinkUpdate";
import Link from "next/link"; import Link from "next/link";
const CarouselTheme: CustomFlowbiteTheme["carousel"] = {
root: {
base: "relative h-full w-full max-w-[300px]",
},
};
export const ReleaseInfoRelated = (props: { export const ReleaseInfoRelated = (props: {
release_id: number; release_id: number;
related: any; related: any;
@ -29,7 +23,7 @@ export const ReleaseInfoRelated = (props: {
)} )}
</div> </div>
<div className="flex justify-center mt-2"> <div className="flex justify-center mt-2">
<Carousel pauseOnHover={true} theme={CarouselTheme}> <Carousel pauseOnHover={true}>
{props.related_releases {props.related_releases
.filter((release: any) => { .filter((release: any) => {
if (release.id == props.release_id) { if (release.id == props.release_id) {
@ -39,7 +33,11 @@ export const ReleaseInfoRelated = (props: {
}) })
.map((release: any) => { .map((release: any) => {
return ( return (
<ReleaseLink key={release.id} {...release} type={"poster"} /> <ReleaseLink
key={release.id}
{...release}
settings={{ showGenres: false, showDescription: false }}
/>
); );
})} })}
</Carousel> </Carousel>

View file

@ -3,6 +3,9 @@ import { ENDPOINTS } from "#/api/config";
import Link from "next/link"; import Link from "next/link";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { tryCatchAPI, useSWRfetcher } from "#/api/utils";
import { toast } from "react-toastify";
import { DropdownItem, ModalHeader, useThemeMode } from "flowbite-react";
const lists = [ const lists = [
{ list: 0, name: "Не смотрю" }, { list: 0, name: "Не смотрю" },
@ -31,25 +34,108 @@ export const ReleaseInfoUserList = (props: {
}) => { }) => {
const [AddReleaseToCollectionModalOpen, setAddReleaseToCollectionModalOpen] = const [AddReleaseToCollectionModalOpen, setAddReleaseToCollectionModalOpen] =
useState(false); useState(false);
const [favButtonDisabled, setFavButtonDisabled] = useState(false);
const [listEventDisabled, setListEventDisabled] = useState(false);
const theme = useThemeMode();
function _addToFavorite() { function _addToFavorite() {
if (props.token) { async function _setFav(url: string) {
props.setIsFavorite(!props.isFavorite); setFavButtonDisabled(true);
if (props.isFavorite) { const tid = toast.loading(
fetch( !props.isFavorite ?
`${ENDPOINTS.user.favorite}/delete/${props.release_id}?token=${props.token}` "Добавляем в избранное..."
); : "Удаляем из избранное...",
} else { {
fetch( position: "bottom-center",
`${ENDPOINTS.user.favorite}/add/${props.release_id}?token=${props.token}` hideProgressBar: true,
); closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
}
);
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render:
!props.isFavorite ?
"Ошибка добавления в избранное"
: "Ошибка удаления из избранного",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setFavButtonDisabled(false);
return;
} }
toast.update(tid, {
render:
!props.isFavorite ? "Добавлено в избранное" : "Удалено из избранного",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
props.setIsFavorite(!props.isFavorite);
setFavButtonDisabled(false);
}
if (props.token) {
let url = `${ENDPOINTS.user.favorite}/add/${props.release_id}?token=${props.token}`;
if (props.isFavorite) {
url = `${ENDPOINTS.user.favorite}/delete/${props.release_id}?token=${props.token}`;
}
_setFav(url);
} }
} }
function _addToList(list: number) { function _addToList(list: number) {
if (props.token) { async function _setList(url: string) {
setListEventDisabled(true);
const tid = toast.loading("Добавляем в список...", {
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
});
const { data, error } = await tryCatchAPI(fetch(url));
if (error) {
toast.update(tid, {
render: `Ошибка добавления в список: ${lists[list].name}`,
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setListEventDisabled(false);
return;
}
toast.update(tid, {
render: `Добавлено в список: ${lists[list].name}`,
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setListEventDisabled(false);
props.setUserList(list); props.setUserList(list);
fetch( }
if (props.token) {
_setList(
`${ENDPOINTS.user.bookmark}/add/${list}/${props.release_id}?token=${props.token}` `${ENDPOINTS.user.bookmark}/add/${list}/${props.release_id}?token=${props.token}`
); );
} }
@ -58,7 +144,7 @@ export const ReleaseInfoUserList = (props: {
return ( return (
<Card className="h-full"> <Card className="h-full">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<Button color={"blue"} size="sm" className="w-full lg:w-auto "> <Button color={"blue"} size="sm" className={props.token ? "w-full sm:w-[49%] lg:w-full 2xl:w-[60%]" : "w-full"}>
<Link href={`/release/${props.release_id}/collections`}> <Link href={`/release/${props.release_id}/collections`}>
Показать в коллекциях{" "} Показать в коллекциях{" "}
<span className="p-1 ml-1 text-gray-500 rounded bg-gray-50"> <span className="p-1 ml-1 text-gray-500 rounded bg-gray-50">
@ -70,14 +156,14 @@ export const ReleaseInfoUserList = (props: {
<Button <Button
color={"blue"} color={"blue"}
size="sm" size="sm"
className="w-full lg:w-auto lg:flex-1" className="w-full sm:w-1/2 lg:w-full 2xl:w-[39%]"
onClick={() => setAddReleaseToCollectionModalOpen(true)} onClick={() => setAddReleaseToCollectionModalOpen(true)}
> >
В коллекцию{" "} В коллекцию{" "}
<span className="w-6 h-6 iconify mdi--bookmark-add "></span> <span className="w-6 h-6 iconify mdi--bookmark-add "></span>
</Button> </Button>
)} )}
{props.token ? ( {props.token ?
<> <>
<Dropdown <Dropdown
label={lists[props.userList].name} label={lists[props.userList].name}
@ -85,14 +171,15 @@ export const ReleaseInfoUserList = (props: {
theme={DropdownTheme} theme={DropdownTheme}
color="blue" color="blue"
size="sm" size="sm"
disabled={listEventDisabled}
> >
{lists.map((list) => ( {lists.map((list) => (
<Dropdown.Item <DropdownItem
key={list.list} key={list.list}
onClick={() => _addToList(list.list)} onClick={() => _addToList(list.list)}
> >
{list.name} {list.name}
</Dropdown.Item> </DropdownItem>
))} ))}
</Dropdown> </Dropdown>
<Button <Button
@ -101,6 +188,7 @@ export const ReleaseInfoUserList = (props: {
_addToFavorite(); _addToFavorite();
}} }}
size="sm" size="sm"
disabled={favButtonDisabled}
> >
<span <span
className={`iconify w-6 h-6 ${ className={`iconify w-6 h-6 ${
@ -109,9 +197,11 @@ export const ReleaseInfoUserList = (props: {
></span> ></span>
</Button> </Button>
</> </>
) : ( : <div className="flex items-center justify-center w-full gap-2 px-2 py-2 text-gray-600 bg-gray-200 rounded-lg dark:text-gray-200 dark:bg-gray-600">
<p>Войдите что-бы добавить в список, избранное или коллекцию</p> <span className="w-6 h-6 iconify material-symbols--info-outline"></span>
)} <p>Войдите что-бы добавить в список, избранное или коллекцию</p>
</div>
}
</div> </div>
<AddReleaseToCollectionModal <AddReleaseToCollectionModal
isOpen={AddReleaseToCollectionModalOpen} isOpen={AddReleaseToCollectionModalOpen}
@ -124,20 +214,6 @@ export const ReleaseInfoUserList = (props: {
); );
}; };
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
const AddReleaseToCollectionModal = (props: { const AddReleaseToCollectionModal = (props: {
isOpen: boolean; isOpen: boolean;
setIsOpen: (isopen: boolean) => void; setIsOpen: (isopen: boolean) => void;
@ -150,10 +226,11 @@ const AddReleaseToCollectionModal = (props: {
if (previousPageData && !previousPageData.content.length) return null; if (previousPageData && !previousPageData.content.length) return null;
return `${ENDPOINTS.collection.userCollections}/${props.profile_id}/${pageIndex}?token=${props.token}`; return `${ENDPOINTS.collection.userCollections}/${props.profile_id}/${pageIndex}?token=${props.token}`;
}; };
const theme = useThemeMode();
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2 } { initialSize: 2 }
); );
@ -188,28 +265,53 @@ const AddReleaseToCollectionModal = (props: {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollPosition]); }, [scrollPosition]);
function _addToCollection(collection: any) {
async function _ToCollection(url: string) {
const tid = toast.loading(
`Добавление в коллекцию ${collection.title}... `,
{
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
}
);
const { data, error } = await tryCatchAPI(fetch(url));
function _addToCollection(collection_id: number) { if (error) {
if (props.token) { let message = `${error.message}, code: ${error.code}`;
fetch( if (error.code == 5) {
`${ENDPOINTS.collection.addRelease}/${collection_id}?release_id=${props.release_id}&token=${props.token}` message = "Релиз уже есть в коллекции";
) }
.then((res) => { toast.update(tid, {
if (!res.ok) { render: message,
alert("Ошибка добавления релиза в коллекцию."); type: "error",
} else { autoClose: 2500,
return res.json(); isLoading: false,
} closeOnClick: true,
}) draggable: true,
.then((data) => { theme: theme.mode == "light" ? "light" : "dark",
if (data.code != 0) {
alert(
"Не удалось добавить релиз в коллекцию, возможно он уже в ней находится."
);
} else {
props.setIsOpen(false);
}
}); });
return;
}
toast.update(tid, {
render: "Релиз добавлен в коллекцию",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
theme: theme.mode == "light" ? "light" : "dark",
});
}
if (props.token) {
_ToCollection(
`${ENDPOINTS.collection.addRelease}/${collection.id}?release_id=${props.release_id}&token=${props.token}`
);
} }
} }
@ -219,31 +321,31 @@ const AddReleaseToCollectionModal = (props: {
show={props.isOpen} show={props.isOpen}
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
> >
<Modal.Header>Выбор коллекции</Modal.Header> <ModalHeader>Выбор коллекции</ModalHeader>
<div <div
className="flex flex-col gap-2 p-4 overflow-y-auto" className="flex flex-col gap-2 p-4 overflow-y-auto"
onScroll={handleScroll} onScroll={handleScroll}
ref={modalRef} ref={modalRef}
> >
{content && content.length > 0 {content && content.length > 0 ?
? content.map((collection) => ( content.map((collection) => (
<button <button
className="relative w-full h-64 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%] " className="relative w-full h-64 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={{ style={{
backgroundImage: `linear-gradient(to bottom, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.9) 100%), url(${collection.image})`, backgroundImage: `linear-gradient(to bottom, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.9) 100%), url(${collection.image})`,
}} }}
key={`collection_${collection.id}`} key={`collection_${collection.id}`}
onClick={() => _addToCollection(collection.id)} onClick={() => _addToCollection(collection)}
> >
<div className="absolute bottom-0 left-0 gap-1 p-2"> <div className="absolute bottom-0 left-0 gap-1 p-2">
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{collection.title} {collection.title}
</p> </p>
<p className="text-gray-400">{collection.description}</p> <p className="text-gray-400">{collection.description}</p>
</div> </div>
</button> </button>
)) ))
: "коллекций не найдено"} : "коллекций не найдено"}
</div> </div>
</Modal> </Modal>
); );

View file

@ -1,133 +0,0 @@
import Link from "next/link";
import { sinceUnixDate } from "#/api/utils";
import { Chip } from "#/components/Chip/Chip";
import Image from "next/image";
const profile_lists = {
// 0: "Не смотрю",
1: { name: "Смотрю", bg_color: "bg-green-500" },
2: { name: "В планах", bg_color: "bg-purple-500" },
3: { name: "Просмотрено", bg_color: "bg-blue-500" },
4: { name: "Отложено", bg_color: "bg-yellow-500" },
5: { name: "Брошено", bg_color: "bg-red-500" },
};
export const ReleaseLink169 = (props: any) => {
const grade = props.grade ? props.grade.toFixed(1) : null;
const profile_list_status = props.profile_list_status;
let user_list = null;
if (profile_list_status != null || profile_list_status != 0) {
user_list = profile_lists[profile_list_status];
}
return (
<Link
href={`/release/${props.id}`}
className={props.isLinkDisabled ? "pointer-events-none" : ""}
aria-disabled={props.isLinkDisabled}
tabIndex={props.isLinkDisabled ? -1 : undefined}
>
<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="absolute flex flex-wrap items-start justify-start gap-0.5 sm:gap-1 left-0 top-0 p-1 sm:p-2">
{grade ? (
<Chip
bg_color={
grade == 0
? "hidden"
: grade < 2
? "bg-red-500"
: grade < 3
? "bg-orange-500"
: grade < 4
? "bg-yellow-500"
: "bg-green-500"
}
name={grade}
/>
) : (
""
)}
{user_list && (
<Chip bg_color={user_list.bg_color} name={user_list.name} />
)}
{props.status ? (
<Chip name={props.status.name} />
) : (
props.status_id != 0 && (
<Chip
name={
props.status_id == 1
? "Завершено"
: props.status_id == 2
? "Онгоинг"
: props.status_id == 3 && "Анонс"
}
/>
)
)}
<Chip
name={props.episodes_released && props.episodes_released}
name_2={
props.episodes_total ? props.episodes_total + " эп." : "? эп."
}
devider="/"
/>
{props.last_view_episode && (
<Chip
name={
props.last_view_episode.name
? props.last_view_episode.name
: `${props.last_view_episode.position + 1} серия`
}
name_2={
"last_view_timestamp" in props &&
sinceUnixDate(props.last_view_timestamp)
}
devider=", "
/>
)}
{props.category && <Chip name={props.category.name} />}
{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-1 sm: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">
{props.genres && (
<p className="text-xs font-light text-white md:text-sm lg:text-base xl:text-lg">
{props.genres}
</p>
)}
<p className="text-sm font-bold text-white md:text-base lg:text-lg xl:text-xl">
{props.title_ru}
</p>
</div>
<p className="text-xs font-light text-white md:text-sm lg:text-base xl:text-lg">
{`${props.description.slice(0, 125)}${
props.description.length > 125 ? "..." : ""
}`}
</p>
</div>
</div>
</div>
</Link>
);
};

View file

@ -1,124 +0,0 @@
import Link from "next/link";
import { sinceUnixDate } from "#/api/utils";
import { Chip } from "#/components/Chip/Chip";
import Image from "next/image";
const profile_lists = {
// 0: "Не смотрю",
1: { name: "Смотрю", bg_color: "bg-green-500" },
2: { name: "В планах", bg_color: "bg-purple-500" },
3: { name: "Просмотрено", bg_color: "bg-blue-500" },
4: { name: "Отложено", bg_color: "bg-yellow-500" },
5: { name: "Брошено", bg_color: "bg-red-500" },
};
export const ReleaseLink169Poster = (props: any) => {
const grade = props.grade ? props.grade.toFixed(1) : null;
const profile_list_status = props.profile_list_status;
let user_list = null;
if (profile_list_status != null || profile_list_status != 0) {
user_list = profile_lists[profile_list_status];
}
return (
<Link
href={`/release/${props.id}`}
className={props.isLinkDisabled ? "pointer-events-none" : ""}
aria-disabled={props.isLinkDisabled}
tabIndex={props.isLinkDisabled ? -1 : undefined}
>
<div className="w-full h-auto p-2 bg-gray-100 rounded-lg dark:bg-slate-800">
<div className="flex w-full h-full gap-2 overflow-hidden">
<div className="flex-shrink-0">
<Image
src={props.image}
height={250}
width={250}
alt={props.title}
className="object-cover aspect-[9/16] h-auto w-24 md:w-32 lg:w-48 rounded-md"
/>
</div>
<div className="flex flex-col flex-1 w-full h-full">
<div>
{props.genres && (
<p className="text-xs font-light text-black dark:text-white md:text-sm lg:text-base xl:text-lg">
{props.genres}
</p>
)}
<p className="text-sm font-bold text-black dark:text-white md:text-base lg:text-lg xl:text-xl">
{`${props.title_ru.slice(0, 47)}${
props.title_ru.length > 47 ? "..." : ""
}`}
</p>
<p className="text-xs font-light text-black dark:text-white md:text-sm lg:text-base xl:text-lg">
{`${props.description.slice(0, 97)}${
props.description.length > 97 ? "..." : ""
}`}
</p>
</div>
<div className="flex flex-wrap gap-1 mt-1">
{grade ? <Chip
bg_color={
grade == 0
? "hidden"
: grade < 2
? "bg-red-500"
: grade < 3
? "bg-orange-500"
: grade < 4
? "bg-yellow-500"
: "bg-green-500"
}
name={grade}
/> : ""}
{user_list && (
<Chip bg_color={user_list.bg_color} name={user_list.name} />
)}
{props.status ? (
<Chip name={props.status.name} />
) : (
props.status_id != 0 && (
<Chip
name={
props.status_id == 1
? "Завершено"
: props.status_id == 2
? "Онгоинг"
: props.status_id == 3 && "Анонс"
}
/>
)
)}
<Chip
name={props.episodes_released && props.episodes_released}
name_2={
props.episodes_total ? props.episodes_total + " эп." : "? эп."
}
devider="/"
/>
{props.last_view_episode && (
<Chip
name={
props.last_view_episode.name
? props.last_view_episode.name
: `${props.last_view_episode.position + 1} серия`
}
name_2={
"last_view_timestamp" in props &&
sinceUnixDate(props.last_view_timestamp)
}
devider=", "
/>
)}
{props.category && <Chip name={props.category.name} />}
{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>
</div>
</div>
</Link>
);
};

View file

@ -1,136 +0,0 @@
import Link from "next/link";
import { sinceUnixDate } from "#/api/utils";
import { Chip } from "#/components/Chip/Chip";
import Image from "next/image";
const profile_lists = {
// 0: "Не смотрю",
1: { name: "Смотрю", bg_color: "bg-green-500" },
2: { name: "В планах", bg_color: "bg-purple-500" },
3: { name: "Просмотрено", bg_color: "bg-blue-500" },
4: { name: "Отложено", bg_color: "bg-yellow-500" },
5: { name: "Брошено", bg_color: "bg-red-500" },
};
const YearSeason = ["_", "Зима", "Весна", "Лето", "Осень"];
export const ReleaseLink169Related = (props: any) => {
const grade = props.grade.toFixed(1);
const profile_list_status = props.profile_list_status;
let user_list = null;
if (profile_list_status != null || profile_list_status != 0) {
user_list = profile_lists[profile_list_status];
}
return (
<Link
href={`/release/${props.id}`}
className={`${
props.isLinkDisabled ? "pointer-events-none" : ""
} flex gap-4 items-center justify-between mx-auto w-full max-w-[1024px]`}
aria-disabled={props.isLinkDisabled}
tabIndex={props.isLinkDisabled ? -1 : undefined}
>
<div className="items-center justify-center flex-1 hidden lg:flex">
<h1 className="inline-block text-6xl font-bold text-center text-transparent bg-gradient-to-r from-blue-600 via-purple-500 to-indigo-500 dark:from-blue-500 dark:via-purple-400 dark:to-indigo-300 bg-clip-text ">
{props.season ? YearSeason[props.season] : ""}
<br/>
{props.year ? props.year : ""}
</h1>
</div>
<div className="w-full max-w-[768px] h-auto p-2 bg-gray-100 rounded-lg dark:bg-slate-800">
<div className="flex w-full h-full gap-2 overflow-hidden">
<div className="flex-shrink-0">
<Image
src={props.image}
height={250}
width={250}
alt={props.title}
className="object-cover aspect-[9/16] lg:aspect-[12/16] h-auto w-24 md:w-32 lg:w-48 rounded-md"
/>
</div>
<div className="flex flex-col flex-1 w-full h-full">
<div>
{props.genres && (
<p className="text-xs font-light text-black dark:text-white md:text-sm lg:text-base xl:text-lg">
{props.genres}
</p>
)}
<p className="block text-sm font-bold text-black dark:text-white md:text-base lg:text-lg xl:text-xl md:hidden">
{`${props.title_ru.slice(0, 47)}${
props.title_ru.length > 47 ? "..." : ""
}`}
</p>
<p className="block text-xs font-light text-black dark:text-white md:text-sm lg:text-base xl:text-lg md:hidden">
{`${props.description.slice(0, 97)}${
props.description.length > 97 ? "..." : ""
}`}
</p>
<p className="hidden text-sm font-bold text-black dark:text-white md:text-base lg:text-lg xl:text-xl md:block max-w-[512px]">
{props.title_ru}
</p>
<p className="hidden text-xs font-light text-black dark:text-white md:text-sm:text-base xl:text-lg md:block max-w-[512px]">
{props.description}
</p>
</div>
<div className="flex flex-wrap gap-1 mt-1">
<Chip
bg_color={
grade == 0
? "hidden"
: grade < 2
? "bg-red-500"
: grade < 3
? "bg-orange-500"
: grade < 4
? "bg-yellow-500"
: "bg-green-500"
}
name={grade}
/>
{user_list && (
<Chip bg_color={user_list.bg_color} name={user_list.name} />
)}
{props.status ? (
<Chip name={props.status.name} />
) : (
props.status_id != 0 && (
<Chip
name={
props.status_id == 1
? "Завершено"
: props.status_id == 2
? "Онгоинг"
: props.status_id == 3 && "Анонс"
}
/>
)
)}
<Chip
name={props.episodes_released && props.episodes_released}
name_2={
props.episodes_total ? props.episodes_total + " эп." : "? эп."
}
devider="/"
/>
{props.category && <Chip name={props.category.name} />}
{props.season || props.year ? (
<Chip
bg_color="lg:hidden bg-gray-500"
name={props.season ? YearSeason[props.season] : ""}
name_2={props.year ? `${props.year} год` : ""}
devider=" "
/>
) : (
""
)}
{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>
</div>
</div>
</Link>
);
};

View file

@ -1,84 +0,0 @@
import Link from "next/link";
import { Chip } from "#/components/Chip/Chip";
const profile_lists = {
// 0: "Не смотрю",
1: { name: "Смотрю", bg_color: "bg-green-500" },
2: { name: "В планах", bg_color: "bg-purple-500" },
3: { name: "Просмотрено", bg_color: "bg-blue-500" },
4: { name: "Отложено", bg_color: "bg-yellow-500" },
5: { name: "Брошено", bg_color: "bg-red-500" },
};
export const ReleaseLinkPoster = (props: any) => {
const grade = props.grade.toFixed(1);
const profile_list_status = props.profile_list_status;
let user_list = null;
if (profile_list_status != null || profile_list_status != 0) {
user_list = profile_lists[profile_list_status];
}
return (
<Link
href={`/release/${props.id}`}
className={props.isLinkDisabled ? "pointer-events-none" : ""}
aria-disabled={props.isLinkDisabled}
tabIndex={props.isLinkDisabled ? -1 : undefined}
>
<div
className="relative w-full h-64 gap-8 p-2 overflow-hidden bg-white bg-center bg-no-repeat bg-cover border border-gray-200 rounded-lg shadow-md lg:min-w-[300px] lg:min-h-[385px] lg:max-w-[300px] lg:max-h-[385px] lg:bg-top dark:border-gray-700 dark:bg-gray-800"
style={{
backgroundImage: `linear-gradient(to bottom, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.9) 100%), url(${props.image})`,
}}
>
<div className="flex flex-wrap gap-1">
<Chip
bg_color={
props.grade.toFixed(1) == 0
? "hidden"
: props.grade.toFixed(1) < 2
? "bg-red-500"
: props.grade.toFixed(1) < 3
? "bg-orange-500"
: props.grade.toFixed(1) < 4
? "bg-yellow-500"
: "bg-green-500"
}
name={props.grade.toFixed(1)}
/>
{props.status ? (
<Chip name={props.status.name} />
) : (
<Chip
name={
props.status_id == 1
? "Завершено"
: props.status_id == 2
? "Онгоинг"
: "Анонс"
}
/>
)}
<Chip
name={props.episodes_released && props.episodes_released}
name_2={
props.episodes_total ? props.episodes_total + " эп." : "? эп."
}
devider="/"
/>
</div>
<div className="absolute flex flex-col gap-2 text-white bottom-4 left-2 right-2">
{props.title_ru && (
<p className="text-xl font-bold text-white md:text-2xl">
{props.title_ru}
</p>
)}
{props.title_original && (
<p className="text-sm text-gray-300 md:text-base">
{props.title_original}
</p>
)}
</div>
</div>
</Link>
);
};

View file

@ -1,19 +0,0 @@
import { ReleaseLink169 } from "./ReleaseLink.16_9FullImage";
import { ReleaseLink169Poster } from "./ReleaseLink.16_9Poster";
import { ReleaseLinkPoster } from "./ReleaseLink.Poster";
export const ReleaseLink = (props: {type?: "16_9"|"poster"}) => {
const type = props.type || "16_9";
if (type == "16_9") {
return (
<>
<div className="hidden lg:block"><ReleaseLink169 {...props} /></div>
<div className="block lg:hidden"><ReleaseLink169Poster {...props} /></div>
</>
)
}
if (type == "poster") {
return <ReleaseLinkPoster {...props} />;
}
};

View file

@ -0,0 +1,43 @@
import Link from "next/link";
import { PosterWithStuff } from "../ReleasePoster/PosterWithStuff";
export const ReleaseLink = (props: {
image: string;
title_ru: string;
title_original: string;
description?: string;
genres?: string;
grade?: number;
id: number;
settings?: {
showGenres?: boolean;
showDescription?: boolean;
};
chipsSettings?: {
enabled: boolean;
gradeHidden?: boolean;
statusHidden?: boolean;
categoryHidden?: boolean;
episodesHidden?: boolean;
listHidden?: boolean;
favHidden?: boolean;
lastWatchedHidden?: boolean;
};
profile_list_status?: number;
status?: {
name: string;
};
category?: {
name: string;
};
status_id?: number;
episodes_released?: string;
episodes_total?: string;
is_favorite?: boolean;
}) => {
return (
<Link href={`/release/${props.id}`}>
<PosterWithStuff {...props} />
</Link>
);
};

View file

@ -67,7 +67,10 @@ export const EpisodeSelector = (props: {
enabled: true, enabled: true,
sensitivity: 4, sensitivity: 4,
}} }}
scrollbar={true} scrollbar={{
enabled: true,
draggable: true,
}}
allowTouchMove={true} allowTouchMove={true}
style={ style={
{ {
@ -79,6 +82,7 @@ export const EpisodeSelector = (props: {
<SwiperSlide <SwiperSlide
key={`episode_${episode.position}`} key={`episode_${episode.position}`}
style={{ maxWidth: "fit-content" }} style={{ maxWidth: "fit-content" }}
className="pb-2"
> >
<Button <Button
color={ color={

View file

@ -590,7 +590,7 @@ export default function Page(props: { children: any, className?: string }) {
<!-- Skip opening Button --> <!-- Skip opening Button -->
<media-seek-forward-button class="media-button" seekoffset="90"> <media-seek-forward-button class="media-button" seekoffset="90">
<svg slot="icon" width="256" height="256" viewBox="-75 -75 400 400"> <svg slot="icon" width="256" height="256" viewBox="-65 -75 400 400">
<path fill="#fff" d="m246.52 118l-88.19-56.13a12 12 0 0 0-12.18-.39A11.66 11.66 0 0 0 140 71.84v44.59L54.33 61.87a12 12 0 0 0-12.18-.39A11.66 11.66 0 0 0 36 71.84v112.32a11.66 11.66 0 0 0 6.15 10.36a12 12 0 0 0 12.18-.39L140 139.57v44.59a11.66 11.66 0 0 0 6.15 10.36a12 12 0 0 0 12.18-.39L246.52 138a11.81 11.81 0 0 0 0-19.94Zm-108.3 13.19L50 187.38a3.91 3.91 0 0 1-4 .13a3.76 3.76 0 0 1-2-3.35V71.84a3.76 3.76 0 0 1 2-3.35a4 4 0 0 1 1.91-.5a3.94 3.94 0 0 1 2.13.63l88.18 56.16a3.8 3.8 0 0 1 0 6.44Zm104 0L154 187.38a3.91 3.91 0 0 1-4 .13a3.76 3.76 0 0 1-2-3.35V71.84a3.76 3.76 0 0 1 2-3.35a4 4 0 0 1 1.91-.5a3.94 3.94 0 0 1 2.13.63l88.18 56.16a3.8 3.8 0 0 1 0 6.44Z" /> <path fill="#fff" d="m246.52 118l-88.19-56.13a12 12 0 0 0-12.18-.39A11.66 11.66 0 0 0 140 71.84v44.59L54.33 61.87a12 12 0 0 0-12.18-.39A11.66 11.66 0 0 0 36 71.84v112.32a11.66 11.66 0 0 0 6.15 10.36a12 12 0 0 0 12.18-.39L140 139.57v44.59a11.66 11.66 0 0 0 6.15 10.36a12 12 0 0 0 12.18-.39L246.52 138a11.81 11.81 0 0 0 0-19.94Zm-108.3 13.19L50 187.38a3.91 3.91 0 0 1-4 .13a3.76 3.76 0 0 1-2-3.35V71.84a3.76 3.76 0 0 1 2-3.35a4 4 0 0 1 1.91-.5a3.94 3.94 0 0 1 2.13.63l88.18 56.16a3.8 3.8 0 0 1 0 6.44Zm104 0L154 187.38a3.91 3.91 0 0 1-4 .13a3.76 3.76 0 0 1-2-3.35V71.84a3.76 3.76 0 0 1 2-3.35a4 4 0 0 1 1.91-.5a3.94 3.94 0 0 1 2.13.63l88.18 56.16a3.8 3.8 0 0 1 0 6.44Z" />
</svg> </svg>
</media-seek-forward-button> </media-seek-forward-button>

View file

@ -3,7 +3,7 @@
import { Spinner } from "#/components/Spinner/Spinner"; import { Spinner } from "#/components/Spinner/Spinner";
import { useUserStore } from "#/store/auth"; import { useUserStore } from "#/store/auth";
import { useUserPlayerPreferencesStore } from "#/store/player"; import { useUserPlayerPreferencesStore } from "#/store/player";
import { Card, Dropdown, Button } from "flowbite-react"; import { Button, Card, Dropdown, DropdownItem } from "flowbite-react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
@ -157,7 +157,6 @@ export const ReleasePlayer = (props: { id: number }) => {
} }
}) })
.catch((err) => { .catch((err) => {
console.log(err);
_setError("Ошибка получение ответа от сервера"); _setError("Ошибка получение ответа от сервера");
return; return;
}); });
@ -271,14 +270,14 @@ export const ReleasePlayer = (props: { id: number }) => {
theme={DropdownTheme} theme={DropdownTheme}
> >
{voiceoverInfo.map((voiceover: any) => ( {voiceoverInfo.map((voiceover: any) => (
<Dropdown.Item <DropdownItem
key={`voiceover_${voiceover.id}`} key={`voiceover_${voiceover.id}`}
onClick={() => onClick={() =>
setSelectedVoiceoverAndSaveAsPreferred(voiceover) setSelectedVoiceoverAndSaveAsPreferred(voiceover)
} }
> >
{voiceover.name} {voiceover.name}
</Dropdown.Item> </DropdownItem>
))} ))}
</Dropdown> </Dropdown>
<Dropdown <Dropdown
@ -287,12 +286,12 @@ export const ReleasePlayer = (props: { id: number }) => {
theme={DropdownTheme} theme={DropdownTheme}
> >
{sourcesInfo.map((source: any) => ( {sourcesInfo.map((source: any) => (
<Dropdown.Item <DropdownItem
key={`source_${source.id}`} key={`source_${source.id}`}
onClick={() => setSelectedPlayerAndSaveAsPreferred(source)} onClick={() => setSelectedPlayerAndSaveAsPreferred(source)}
> >
{source.name} {source.name}
</Dropdown.Item> </DropdownItem>
))} ))}
</Dropdown> </Dropdown>
</div> </div>
@ -315,9 +314,12 @@ export const ReleasePlayer = (props: { id: number }) => {
direction={"horizontal"} direction={"horizontal"}
mousewheel={{ mousewheel={{
enabled: true, enabled: true,
sensitivity: 2, sensitivity: 4,
}}
scrollbar={{
enabled: true,
draggable: true,
}} }}
scrollbar={true}
allowTouchMove={true} allowTouchMove={true}
style={ style={
{ {
@ -329,6 +331,7 @@ export const ReleasePlayer = (props: { id: number }) => {
<SwiperSlide <SwiperSlide
key={`episode_${episode.position}`} key={`episode_${episode.position}`}
style={{ maxWidth: "fit-content" }} style={{ maxWidth: "fit-content" }}
className="pb-2"
> >
<Button <Button
color={ color={

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { Card } from "flowbite-react"; import { Button, Card } from "flowbite-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
@ -14,6 +14,7 @@ import HlsVideo from "hls-video-element/react";
import VideoJS from "videojs-video-element/react"; import VideoJS from "videojs-video-element/react";
import MediaThemeSutro from "./MediaThemeSutro"; import MediaThemeSutro from "./MediaThemeSutro";
import { getAnonEpisodesWatched } from "./ReleasePlayer"; import { getAnonEpisodesWatched } from "./ReleasePlayer";
import { tryCatchPlayer, tryCatchAPI } from "#/api/utils";
export const ReleasePlayerCustom = (props: { export const ReleasePlayerCustom = (props: {
id: number; id: number;
@ -38,197 +39,254 @@ export const ReleasePlayerCustom = (props: {
useCustom: false, useCustom: false,
}); });
const [playbackRate, setPlaybackRate] = useState(1); const [playbackRate, setPlaybackRate] = useState(1);
const [playerError, setPlayerError] = useState(null);
const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const playerPreferenceStore = useUserPlayerPreferencesStore(); const playerPreferenceStore = useUserPlayerPreferencesStore();
const preferredVO = playerPreferenceStore.getPreferredVoiceover(props.id); const preferredVO = playerPreferenceStore.getPreferredVoiceover(props.id);
const preferredSource = playerPreferenceStore.getPreferredPlayer(props.id); const preferredSource = playerPreferenceStore.getPreferredPlayer(props.id);
const _fetchVoiceover = async (release_id: number) => { async function _fetchAPI(
let url = `${ENDPOINTS.release.episode}/${release_id}`; url: string,
if (props.token) { onErrorMsg: string,
url += `?token=${props.token}`; onErrorCodes?: Record<number, string>
} ) {
const response = await fetch(url); const { data, error } = await tryCatchAPI(fetch(url));
const data = await response.json(); if (error) {
return data; let errorDetail = "Мы правда не знаем что произошло...";
};
const _fetchSource = async (release_id: number, voiceover_id: number) => { if (error.name) {
const response = await fetch( if (error.name == "TypeError") {
`${ENDPOINTS.release.episode}/${release_id}/${voiceover_id}` errorDetail = "Не удалось подключиться к серверу";
); } else {
const data = await response.json(); errorDetail = `Неизвестная ошибка ${error.name}: ${error.message}`;
return data; }
}; }
if (error.code) {
if (Object.keys(onErrorCodes).includes(error.code.toString())) {
errorDetail = onErrorCodes[error.code.toString()];
} else {
errorDetail = `API вернуло ошибку: ${error.code}`;
}
}
const _fetchEpisode = async ( setPlayerError({
release_id: number, message: onErrorMsg,
voiceover_id: number, detail: errorDetail,
source_id: number });
) => { return null;
let url = `${ENDPOINTS.release.episode}/${release_id}/${voiceover_id}/${source_id}`;
if (props.token) {
url += `?token=${props.token}`;
} }
const response = await fetch(url);
const data = await response.json();
return data; return data;
}; }
async function _fetchPlayer(url: string) {
const { data, error } = (await tryCatchPlayer(fetch(url))) as any;
if (error) {
let errorDetail = "Мы правда не знаем что произошло...";
if (error.name) {
if (error.name == "TypeError") {
errorDetail = "Не удалось подключиться к серверу";
} else {
errorDetail = `Неизвестная ошибка ${error.name}: ${error.message}`;
}
} else if (error.message) {
errorDetail = error.message;
}
setPlayerError({
message: "Не удалось получить ссылку на видео",
detail: errorDetail,
});
return null;
}
return data;
}
const _fetchKodikManifest = async (url: string) => { const _fetchKodikManifest = async (url: string) => {
const response = await fetch( const data = await _fetchPlayer(
`https://anix-player.wah.su/?url=${url}&player=kodik` `https://anix-player.wah.su/?url=${url}&player=kodik`
); );
const data = await response.json(); if (data) {
let lowQualityLink = data.links["360"][0].src; let lowQualityLink = data.links["360"][0].src;
if (lowQualityLink.includes("https://")) { if (lowQualityLink.includes("https://")) {
lowQualityLink = lowQualityLink.replace("https://", "//"); lowQualityLink = lowQualityLink.replace("https://", "//");
}
let manifest = `https:${lowQualityLink.replace("360.mp4:hls:", "")}`;
let poster = `https:${lowQualityLink.replace("360.mp4:hls:manifest.m3u8", "thumb001.jpg")}`;
if (lowQualityLink.includes("animetvseries")) {
let blobTxt = "#EXTM3U\n";
if (data.links.hasOwnProperty("240")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=427x240,BANDWIDTH=200000\n";
!data.links["240"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["240"][0].src}\n`)
: (blobTxt += `${data.links["240"][0].src}\n`);
}
if (data.links.hasOwnProperty("360")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=578x360,BANDWIDTH=400000\n";
!data.links["360"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["360"][0].src}\n`)
: (blobTxt += `${data.links["360"][0].src}\n`);
}
if (data.links.hasOwnProperty("480")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n";
!data.links["480"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["480"][0].src}\n`)
: (blobTxt += `${data.links["480"][0].src}\n`);
}
if (data.links.hasOwnProperty("720")) {
blobTxt +=
"#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n";
!data.links["720"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["720"][0].src}\n`)
: (blobTxt += `${data.links["720"][0].src}\n`);
}
let file = new File([blobTxt], "manifest.m3u8", {
type: "application/x-mpegURL",
});
manifest = URL.createObjectURL(file);
}
return { manifest, poster };
} }
let manifest = `https:${lowQualityLink.replace("360.mp4:hls:", "")}`; return { manifest: null, poster: null };
let poster = `https:${lowQualityLink.replace("360.mp4:hls:manifest.m3u8", "thumb001.jpg")}`;
if (lowQualityLink.includes("animetvseries")) {
let blobTxt = "#EXTM3U\n";
if (data.links.hasOwnProperty("240")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=427x240,BANDWIDTH=200000\n";
!data.links["240"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["240"][0].src}\n`)
: (blobTxt += `${data.links["240"][0].src}\n`);
}
if (data.links.hasOwnProperty("360")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=578x360,BANDWIDTH=400000\n";
!data.links["360"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["360"][0].src}\n`)
: (blobTxt += `${data.links["360"][0].src}\n`);
}
if (data.links.hasOwnProperty("480")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n";
!data.links["480"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["480"][0].src}\n`)
: (blobTxt += `${data.links["480"][0].src}\n`);
}
if (data.links.hasOwnProperty("720")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n";
!data.links["720"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["720"][0].src}\n`)
: (blobTxt += `${data.links["720"][0].src}\n`);
}
let file = new File([blobTxt], "manifest.m3u8", {
type: "application/x-mpegURL",
});
manifest = URL.createObjectURL(file);
}
return { manifest, poster };
}; };
const _fetchAnilibriaManifest = async (url: string) => { const _fetchAnilibriaManifest = async (url: string) => {
const id = url.split("?id=")[1].split("&ep=")[0]; const id = url.split("?id=")[1].split("&ep=")[0];
const data = await _fetchPlayer(
`https://api.anilibria.tv/v3/title?id=${id}`
);
if (data) {
const host = `https://${data.player.host}`;
const ep = data.player.list[episode.selected.position];
const response = await fetch(`https://api.anilibria.tv/v3/title?id=${id}`); const blobTxt = `#EXTM3U\n${ep.hls.sd && `#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n${host}${ep.hls.sd}\n`}${ep.hls.hd && `#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n${host}${ep.hls.hd}\n`}${ep.hls.fhd && `#EXT-X-STREAM-INF:RESOLUTION=1920x1080,BANDWIDTH=2560000\n${host}${ep.hls.fhd}\n`}`;
const data = await response.json(); let file = new File([blobTxt], "manifest.m3u8", {
type: "application/x-mpegURL",
const host = `https://${data.player.host}`; });
const ep = data.player.list[episode.selected.position]; let manifest = URL.createObjectURL(file);
let poster = `https://anixart.libria.fun${ep.preview}`;
const blobTxt = `#EXTM3U\n${ep.hls.sd && `#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n${host}${ep.hls.sd}\n`}${ep.hls.hd && `#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n${host}${ep.hls.hd}\n`}${ep.hls.fhd && `#EXT-X-STREAM-INF:RESOLUTION=1920x1080,BANDWIDTH=2560000\n${host}${ep.hls.fhd}\n`}`; return { manifest, poster };
let file = new File([blobTxt], "manifest.m3u8", { }
type: "application/x-mpegURL", return { manifest: null, poster: null };
});
let manifest = URL.createObjectURL(file);
let poster = `https://anixart.libria.fun${ep.preview}`;
return { manifest, poster };
}; };
const _fetchSibnetManifest = async (url: string) => { const _fetchSibnetManifest = async (url: string) => {
const response = await fetch( const data = await _fetchPlayer(
`https://sibnet.anix-player.wah.su/?url=${url}` `https://sibnet.anix-player.wah.su/?url=${url}`
); );
const data = await response.json(); if (data) {
let manifest = data.video;
let manifest = data.video; let poster = data.poster;
let poster = data.poster; return { manifest, poster };
return { manifest, poster }; }
return { manifest: null, poster: null };
}; };
useEffect(() => { useEffect(() => {
const __getInfo = async () => { const __getInfo = async () => {
const vo = await _fetchVoiceover(props.id); let url = `${ENDPOINTS.release.episode}/${props.id}`;
const selectedVO = if (props.token) {
vo.types.find((voiceover: any) => voiceover.name === preferredVO) || url += `?token=${props.token}`;
vo.types[0]; }
setVoiceover({ const vo = await _fetchAPI(
selected: selectedVO, url,
available: vo.types, "Не удалось получить информацию о озвучках",
}); { 1: "Просмотр запрещён" }
);
if (vo) {
const selectedVO =
vo.types.find((voiceover: any) => voiceover.name === preferredVO) ||
vo.types[0];
setVoiceover({
selected: selectedVO,
available: vo.types,
});
}
}; };
__getInfo(); __getInfo();
}, []); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.id, props.token]);
useEffect(() => { useEffect(() => {
const __getInfo = async () => { const __getInfo = async () => {
const src = await _fetchSource(props.id, voiceover.selected.id); let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}`;
const selectedSrc = const src = await _fetchAPI(
src.sources.find((source: any) => source.name === preferredSource) || url,
src.sources[0]; "Не удалось получить информацию о источниках"
if (selectedSrc.episodes_count == 0) { );
const remSources = src.sources.filter( if (src) {
(source: any) => source.id !== selectedSrc.id const selectedSrc =
); src.sources.find((source: any) => source.name === preferredSource) ||
src.sources[0];
if (selectedSrc.episodes_count == 0) {
const remSources = src.sources.filter(
(source: any) => source.id !== selectedSrc.id
);
setSource({
selected: remSources[0],
available: remSources,
});
return;
}
setSource({ setSource({
selected: remSources[0], selected: selectedSrc,
available: remSources, available: src.sources,
}); });
return;
} }
setSource({
selected: selectedSrc,
available: src.sources,
});
}; };
if (voiceover.selected) { if (voiceover.selected) {
__getInfo(); __getInfo();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [voiceover.selected]); }, [voiceover.selected]);
useEffect(() => { useEffect(() => {
const __getInfo = async () => { const __getInfo = async () => {
const episodes = await _fetchEpisode( let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}/${source.selected.id}`;
props.id, if (props.token) {
voiceover.selected.id, url += `?token=${props.token}`;
source.selected.id }
const episodes = await _fetchAPI(
url,
"Не удалось получить информацию о эпизодах"
); );
if (episodes) {
let anonEpisodesWatched = getAnonEpisodesWatched(
props.id,
source.selected.id,
voiceover.selected.id
);
let lastEpisodeWatched = Math.max.apply(
0,
Object.keys(
anonEpisodesWatched[props.id][source.selected.id][
voiceover.selected.id
]
)
);
let selectedEpisode =
episodes.episodes.find(
(episode: any) => episode.position == lastEpisodeWatched
) || episodes.episodes[0];
let anonEpisodesWatched = getAnonEpisodesWatched( setEpisode({
props.id, selected: selectedEpisode,
source.selected.id, available: episodes.episodes,
voiceover.selected.id });
); }
let lastEpisodeWatched = Math.max.apply(
0,
Object.keys(
anonEpisodesWatched[props.id][source.selected.id][
voiceover.selected.id
]
)
);
let selectedEpisode =
episodes.episodes.find(
(episode: any) => episode.position == lastEpisodeWatched
) || episodes.episodes[0];
setEpisode({
selected: selectedEpisode,
available: episodes.episodes,
});
}; };
if (source.selected) { if (source.selected) {
__getInfo(); __getInfo();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [source.selected]); }, [source.selected]);
useEffect(() => { useEffect(() => {
@ -237,36 +295,45 @@ export const ReleasePlayerCustom = (props: {
const { manifest, poster } = await _fetchKodikManifest( const { manifest, poster } = await _fetchKodikManifest(
episode.selected.url episode.selected.url
); );
SetPlayerProps({ if (manifest) {
src: manifest, SetPlayerProps({
poster: poster, src: manifest,
useCustom: true, poster: poster,
type: "hls", useCustom: true,
}); type: "hls",
});
setIsLoading(false);
}
return; return;
} }
if (source.selected.name == "Libria") { if (source.selected.name == "Libria") {
const { manifest, poster } = await _fetchAnilibriaManifest( const { manifest, poster } = await _fetchAnilibriaManifest(
episode.selected.url episode.selected.url
); );
SetPlayerProps({ if (manifest) {
src: manifest, SetPlayerProps({
poster: poster, src: manifest,
useCustom: true, poster: poster,
type: "hls", useCustom: true,
}); type: "hls",
});
setIsLoading(false);
}
return; return;
} }
if (source.selected.name == "Sibnet") { if (source.selected.name == "Sibnet") {
const { manifest, poster } = await _fetchSibnetManifest( const { manifest, poster } = await _fetchSibnetManifest(
episode.selected.url episode.selected.url
); );
SetPlayerProps({ if (manifest) {
src: manifest, SetPlayerProps({
poster: poster, src: manifest,
useCustom: true, poster: poster,
type: "mp4", useCustom: true,
}); type: "mp4",
});
setIsLoading(false);
}
return; return;
} }
SetPlayerProps({ SetPlayerProps({
@ -275,39 +342,59 @@ export const ReleasePlayerCustom = (props: {
useCustom: false, useCustom: false,
type: null, type: null,
}); });
setIsLoading(false);
}; };
if (episode.selected) { if (episode.selected) {
setIsLoading(true);
setPlayerError(null);
__getInfo(); __getInfo();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [episode.selected]); }, [episode.selected]);
return ( return (
<Card className="h-full"> <Card className="aspect-video min-h-min-h-[300px] sm:min-h-[466px] md:min-h-[540px] lg:min-h-[512px] xl:min-h-[608px] 2xl:min-h-[712px]">
{( <div className="flex flex-wrap gap-4">
!voiceover.selected || {voiceover.selected && (
!source.selected || <VoiceoverSelector
!episode.selected || availableVoiceover={voiceover.available}
!playerProps.src voiceover={voiceover.selected}
) ? setVoiceover={setVoiceover}
<div className="flex items-center justify-center w-full aspect-video"> release_id={props.id}
<Spinner /> />
</div> )}
: <div className="flex flex-col gap-4"> {source.selected && (
<div className="flex flex-wrap gap-4"> <SourceSelector
<VoiceoverSelector availableSource={source.available}
availableVoiceover={voiceover.available} source={source.selected}
voiceover={voiceover.selected} setSource={setSource}
setVoiceover={setVoiceover} release_id={props.id}
release_id={props.id} />
/> )}
<SourceSelector </div>
availableSource={source.available}
source={source.selected} <div className="flex items-center justify-center w-full h-full">
setSource={setSource} {isLoading ?
release_id={props.id} !playerError ?
/> <Spinner />
</div> : <div className="flex flex-col gap-2">
{playerProps.useCustom ? <p className="text-lg font-bold">Ошибка: {playerError.message}</p>
{!isErrorDetailsOpen ?
<Button
color="light"
size="xs"
onClick={() => setIsErrorDetailsOpen(true)}
>
Подробнее
</Button>
: <p className="text-gray-600 dark:text-gray-100">
{playerError.detail}
</p>
}
</div>
: playerProps.useCustom ?
!playerError ?
<MediaThemeSutro className="object-none w-full aspect-video"> <MediaThemeSutro className="object-none w-full aspect-video">
{playerProps.type == "hls" ? {playerProps.type == "hls" ?
<HlsVideo <HlsVideo
@ -334,7 +421,27 @@ export const ReleasePlayerCustom = (props: {
></VideoJS> ></VideoJS>
} }
</MediaThemeSutro> </MediaThemeSutro>
: <iframe src={playerProps.src} className="w-full aspect-video" />} : <div className="flex flex-col gap-2">
<p className="text-lg font-bold">Ошибка: {playerError.message}</p>
{!isErrorDetailsOpen ?
<Button
color="light"
size="xs"
onClick={() => setIsErrorDetailsOpen(true)}
>
Подробнее
</Button>
: <p className="text-gray-600 dark:text-gray-100">
{playerError.detail}
</p>
}
</div>
: <iframe src={playerProps.src} className="w-full aspect-video" allowFullScreen={true} />}
</div>
<div>
{episode.selected && source.selected && voiceover.selected && (
<EpisodeSelector <EpisodeSelector
availableEpisodes={episode.available} availableEpisodes={episode.available}
episode={episode.selected} episode={episode.selected}
@ -344,8 +451,8 @@ export const ReleasePlayerCustom = (props: {
voiceover={voiceover.selected} voiceover={voiceover.selected}
token={props.token} token={props.token}
/> />
</div> )}
} </div>
</Card> </Card>
); );
}; };

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { Dropdown } from "flowbite-react"; import { Dropdown, DropdownItem } from "flowbite-react";
import { numberDeclension } from "#/api/utils"; import { numberDeclension } from "#/api/utils";
import { useUserPlayerPreferencesStore } from "#/store/player"; import { useUserPlayerPreferencesStore } from "#/store/player";
@ -20,7 +20,7 @@ const DropdownTrigger = ({ name }: Source) => {
); );
}; };
const DropdownItem = ({ name, episodes_count }: Source) => { const DropdownItemInternal = ({ name, episodes_count }: Source) => {
return ( return (
<div className="flex flex-col gap-2 cursor-pointer"> <div className="flex flex-col gap-2 cursor-pointer">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -55,7 +55,7 @@ export const SourceSelector = (props: {
)} )}
> >
{props.availableSource.map((source: Source) => ( {props.availableSource.map((source: Source) => (
<Dropdown.Item <DropdownItem
key={`source_${source.id}`} key={`source_${source.id}`}
onClick={() => { onClick={() => {
playerPreferenceStore.setPreferredPlayer( playerPreferenceStore.setPreferredPlayer(
@ -68,8 +68,8 @@ export const SourceSelector = (props: {
}); });
}} }}
> >
<DropdownItem {...source} /> <DropdownItemInternal {...source} />
</Dropdown.Item> </DropdownItem>
))} ))}
</Dropdown> </Dropdown>
); );

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { Dropdown } from "flowbite-react"; import { Dropdown, DropdownItem } from "flowbite-react";
import { numberDeclension } from "#/api/utils"; import { numberDeclension } from "#/api/utils";
import { useUserPlayerPreferencesStore } from "#/store/player"; import { useUserPlayerPreferencesStore } from "#/store/player";
@ -16,7 +16,8 @@ interface Voiceover {
const DropdownTrigger = ({ icon, name, pinned }: Voiceover) => { const DropdownTrigger = ({ icon, name, pinned }: Voiceover) => {
return ( return (
<div className="flex items-center gap-2 cursor-pointer"> <div className="flex items-center gap-2 cursor-pointer">
{icon && <img className="w-6 h-6 rounded-full" src={icon}></img>} {/* eslint-disable-next-line @next/next/no-img-element */}
{icon && <img alt="" className="w-6 h-6 rounded-full" src={icon}></img>}
<p>{name}</p> <p>{name}</p>
{pinned && ( {pinned && (
<span className="h-6 bg-gray-700 dark:bg-gray-300 iconify material-symbols--push-pin"></span> <span className="h-6 bg-gray-700 dark:bg-gray-300 iconify material-symbols--push-pin"></span>
@ -26,7 +27,7 @@ const DropdownTrigger = ({ icon, name, pinned }: Voiceover) => {
); );
}; };
const DropdownItem = ({ const DropdownItemInternal = ({
icon, icon,
name, name,
pinned, pinned,
@ -36,7 +37,8 @@ const DropdownItem = ({
return ( return (
<div className="flex flex-col gap-2 cursor-pointer"> <div className="flex flex-col gap-2 cursor-pointer">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{icon && <img className="w-6 h-6 rounded-full" src={icon}></img>} {/* eslint-disable-next-line @next/next/no-img-element */}
{icon && <img alt="" className="w-6 h-6 rounded-full" src={icon}></img>}
<p>{name}</p> <p>{name}</p>
{pinned && ( {pinned && (
<span className="h-6 iconify material-symbols--push-pin"></span> <span className="h-6 iconify material-symbols--push-pin"></span>
@ -80,7 +82,7 @@ export const VoiceoverSelector = (props: {
)} )}
> >
{props.availableVoiceover.map((voiceover: Voiceover) => ( {props.availableVoiceover.map((voiceover: Voiceover) => (
<Dropdown.Item <DropdownItem
className="w-fit" className="w-fit"
key={`voiceover_${voiceover.id}`} key={`voiceover_${voiceover.id}`}
onClick={() => { onClick={() => {
@ -94,8 +96,8 @@ export const VoiceoverSelector = (props: {
}); });
}} }}
> >
<DropdownItem {...voiceover} /> <DropdownItemInternal {...voiceover} />
</Dropdown.Item> </DropdownItem>
))} ))}
</Dropdown> </Dropdown>
); );

View file

@ -0,0 +1,103 @@
import { sinceUnixDate } from "#/api/utils";
import { Chip } from "../Chip/Chip";
interface ChipProps {
settings?: any;
grade?: any;
status?: any;
status_id?: any;
user_list?: any;
episodes_released?: any;
episodes_total?: any;
category?: any;
is_favorite?: any;
last_view_episode?: any;
last_view_timestamp?: any;
}
export const ReleaseChips = ({
settings,
grade,
status,
status_id,
user_list,
episodes_released,
episodes_total,
category,
is_favorite,
last_view_episode,
last_view_timestamp,
}: ChipProps) => {
const chipSettings = {
enabled: true,
gradeHidden: false,
statusHidden: false,
categoryHidden: false,
episodesHidden: false,
listHidden: false,
favHidden: false,
lastWatchedHidden: true,
...settings,
};
return (
<div className={`${chipSettings.enabled ? "flex" : "hidden"} gap-1 flex-wrap`}>
{!chipSettings.gradeHidden && grade ?
<Chip
className="w-12"
bg_color={
grade == 0 ? "hidden"
: grade < 2 ?
"bg-red-500"
: grade < 3 ?
"bg-orange-500"
: grade < 4 ?
"bg-yellow-500"
: "bg-green-500"
}
name={`${grade}`}
/>
: ""}
{!chipSettings.listHidden && user_list && (
<Chip bg_color={user_list.bg_color} name={user_list.name} />
)}
{!chipSettings.statusHidden && status ?
<Chip name={status.name} />
: status_id != 0 && (
<Chip
name={
status_id == 1 ? "Завершено"
: status_id == 2 ?
"Онгоинг"
: status_id == 3 && "Анонс"
}
/>
)
}
{!chipSettings.episodesHidden && (
<Chip
name={episodes_released && episodes_released}
name_2={episodes_total ? episodes_total + " эп." : "? эп."}
devider="/"
/>
)}
{!chipSettings.categoryHidden && category && <Chip name={category.name} />}
{!chipSettings.favHidden && 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>
)}
{!chipSettings.lastWatchedHidden && last_view_episode && (
<Chip
name={
last_view_episode.name ?
last_view_episode.name
: `${last_view_episode.position + 1} серия`
}
name_2={last_view_timestamp && sinceUnixDate(last_view_timestamp)}
devider=", "
/>
)}
</div>
);
};

View file

@ -0,0 +1,22 @@
import Image from "next/image";
export const Poster = (props: {
image: string;
alt?: string;
className?: string;
}) => {
return (
<Image
className={`object-cover rounded-lg shadow-md ${props.className}`}
// className="w-[285px] max-h-[385px] object-cover border border-gray-200 rounded-lg shadow-md dark:border-gray-700"
src={props.image}
alt={props.alt || ""}
width={285}
height={385}
style={{
width: "100%",
height: "auto",
}}
/>
);
};

View file

@ -0,0 +1,117 @@
import { Poster } from "./Poster";
import { ReleaseChips } from "./Chips";
const profile_lists = {
// 0: "Не смотрю",
1: { name: "Смотрю", bg_color: "bg-green-500" },
2: { name: "В планах", bg_color: "bg-purple-500" },
3: { name: "Просмотрено", bg_color: "bg-blue-500" },
4: { name: "Отложено", bg_color: "bg-yellow-500" },
5: { name: "Брошено", bg_color: "bg-red-500" },
};
export const PosterWithStuff = (props: {
image: string;
title_ru: string;
title_original: string;
description?: string;
genres?: string;
grade?: number;
id: number;
settings?: {
showGenres?: boolean;
showDescription?: boolean;
};
chipsSettings?: {
enabled: boolean;
gradeHidden?: boolean;
statusHidden?: boolean;
categoryHidden?: boolean;
episodesHidden?: boolean;
listHidden?: boolean;
favHidden?: boolean;
lastWatchedHidden?: boolean;
};
profile_list_status?: number;
status?: {
name: string;
};
category?: {
name: string;
};
status_id?: number;
episodes_released?: string;
episodes_total?: string;
is_favorite?: boolean;
}) => {
const genres = [];
const settings = {
showGenres: true,
showDescription: true,
...props.settings,
};
const chipsSettings = props.chipsSettings || {}
const grade = props.grade ? Number(props.grade.toFixed(1)) : null;
const profile_list_status = props.profile_list_status || null;
let user_list = null;
if (profile_list_status != null || profile_list_status != 0) {
user_list = profile_lists[profile_list_status];
}
if (props.genres) {
const genres_array = props.genres.split(",");
genres_array.forEach((genre) => {
genres.push(genre.trim());
});
}
return (
<div className="relative w-full h-full overflow-hidden rounded-lg group">
<div className="absolute z-20 top-2 left-2 right-2">
<ReleaseChips
{...props}
user_list={user_list}
grade={grade}
settings={chipsSettings}
></ReleaseChips>
</div>
<div className="absolute z-20 bottom-2 left-2 right-2 lg:translate-y-[100%] group-hover:lg:translate-y-0 transition-transform">
<div className="lg:-translate-y-[calc(100%_+_1rem)] group-hover:lg:translate-y-0 transition-transform">
{settings.showGenres &&
genres.length > 0 &&
genres.map((genre: string, index: number) => {
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"
>
{index > 0 && ", "}
{genre}
</span>
);
})}
{props.title_ru && (
<p className="py-1 text-xl font-bold leading-none text-white md:text-2xl md:py-0">
{props.title_ru}
</p>
)}
{props.title_original && (
<p className="text-sm leading-none text-gray-300 md:text-base">
{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">
{props.description}
</p>
)}
</div>
<div className="absolute w-full h-full rounded-b-lg bg-gradient-to-t from-black to-transparent"></div>
<Poster
image={props.image}
className="w-auto h-auto min-w-full min-h-full flex-grow-1"
></Poster>
</div>
);
};

View file

@ -1,4 +1,4 @@
import { ReleaseLink } from "../ReleaseLink/ReleaseLink"; import { ReleaseLink } from "../ReleaseLink/ReleaseLinkUpdate";
export const ReleaseSection = (props: { export const ReleaseSection = (props: {
sectionTitle?: string; sectionTitle?: string;
@ -14,15 +14,22 @@ export const ReleaseSection = (props: {
</div> </div>
)} )}
<div className="m-4"> <div className="m-4">
<div className="lg:justify-center lg:grid-cols-[repeat(auto-fit,minmax(400px,1fr))] gap-4 lg:gap-2 min-w-full flex flex-col lg:grid"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{props.content.map((release) => { {props.content.map((release) => {
return ( return (
<div key={release.id} className="w-full h-full lg:aspect-video"> <div key={release.id} className="w-full h-full">
<ReleaseLink {...release} /> <ReleaseLink
{...release}
chipsSettings={{
enabled: true,
lastWatchedHidden:
(props.sectionTitle &&
props.sectionTitle.toLowerCase() != "история")
}}
/>
</div> </div>
); );
})} })}
{props.content.length == 1 && <div></div>}
</div> </div>
</div> </div>
</section> </section>

View file

@ -1,13 +1,19 @@
"use client"; "use client";
import { CURRENT_APP_VERSION } from "#/api/config";
import { useUserStore } from "#/store/auth";
import { usePreferencesStore } from "#/store/preferences"; import { usePreferencesStore } from "#/store/preferences";
import { import {
Modal,
Button, Button,
useThemeMode, ButtonGroup,
ToggleSwitch,
HR,
Dropdown, Dropdown,
DropdownItem,
HR,
Modal,
ModalBody,
ModalHeader,
ToggleSwitch,
useThemeMode,
} from "flowbite-react"; } from "flowbite-react";
import Link from "next/link"; import Link from "next/link";
@ -32,10 +38,17 @@ const NavbarTitles = {
links: "Только ссылки", links: "Только ссылки",
selected: "Только выбранные", selected: "Только выбранные",
never: "Никогда", never: "Никогда",
} };
const FifthButton = {
3: "Избранное",
4: "Коллекции",
5: "История",
};
export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => { export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
const preferenceStore = usePreferencesStore(); const preferenceStore = usePreferencesStore();
const userStore = useUserStore();
const { computedMode, setMode } = useThemeMode(); const { computedMode, setMode } = useThemeMode();
@ -45,8 +58,8 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
show={props.isOpen} show={props.isOpen}
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
> >
<Modal.Header>Настройки</Modal.Header> <ModalHeader>Настройки</ModalHeader>
<Modal.Body> <ModalBody>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-6 h-6 iconify material-symbols--palette-outline"></span> <span className="w-6 h-6 iconify material-symbols--palette-outline"></span>
@ -54,20 +67,20 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className=" dark:text-white">Тема</p> <p className=" dark:text-white">Тема</p>
<Button.Group> <ButtonGroup>
<Button <Button
color={computedMode == "light" ? "blue" : "gray"} color={computedMode == "light" ? "blue" : "light"}
onClick={() => setMode("light")} onClick={() => setMode("light")}
> >
Светлая Светлая
</Button> </Button>
<Button <Button
color={computedMode == "dark" ? "blue" : "gray"} color={computedMode == "dark" ? "blue" : "light"}
onClick={() => setMode("dark")} onClick={() => setMode("dark")}
> >
Темная Темная
</Button> </Button>
</Button.Group> </ButtonGroup>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className=" dark:text-white max-w-96"> <p className=" dark:text-white max-w-96">
@ -76,15 +89,6 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
</p> </p>
<ToggleSwitch <ToggleSwitch
color="blue" color="blue"
theme={{
toggle: {
checked: {
color: {
blue: "border-blue-700 bg-blue-700",
},
},
},
}}
onChange={() => onChange={() =>
preferenceStore.setParams({ preferenceStore.setParams({
skipToCategory: { skipToCategory: {
@ -112,7 +116,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
> >
{Object.keys(HomeCategory).map((key) => { {Object.keys(HomeCategory).map((key) => {
return ( return (
<Dropdown.Item <DropdownItem
key={key} key={key}
onClick={() => onClick={() =>
preferenceStore.setParams({ preferenceStore.setParams({
@ -124,7 +128,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
} }
> >
{HomeCategory[key]} {HomeCategory[key]}
</Dropdown.Item> </DropdownItem>
); );
})} })}
</Dropdown> </Dropdown>
@ -143,7 +147,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
> >
{Object.keys(BookmarksCategory).map((key) => { {Object.keys(BookmarksCategory).map((key) => {
return ( return (
<Dropdown.Item <DropdownItem
key={key} key={key}
onClick={() => onClick={() =>
preferenceStore.setParams({ preferenceStore.setParams({
@ -155,7 +159,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
} }
> >
{BookmarksCategory[key]} {BookmarksCategory[key]}
</Dropdown.Item> </DropdownItem>
); );
})} })}
</Dropdown> </Dropdown>
@ -168,26 +172,66 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
</p> </p>
<Dropdown <Dropdown
color="blue" color="blue"
label={ label={NavbarTitles[preferenceStore.flags.showNavbarTitles]}
NavbarTitles[preferenceStore.flags.showNavbarTitles]
}
> >
{Object.keys(NavbarTitles).map((key: "always" | "links" | "selected" | "never") => { {Object.keys(NavbarTitles).map(
return ( (key: "always" | "links" | "selected" | "never") => {
<Dropdown.Item return (
key={`navbar-titles-${key}`} <DropdownItem
onClick={() => className={`${key == "links" ? "hidden lg:flex" : ""}`}
preferenceStore.setFlags({ key={`navbar-titles-${key}`}
showNavbarTitles: key, onClick={() =>
}) preferenceStore.setFlags({
} showNavbarTitles: key,
> })
{NavbarTitles[key]} }
</Dropdown.Item> >
); {NavbarTitles[key]}
})} </DropdownItem>
);
}
)}
</Dropdown> </Dropdown>
</div> </div>
{userStore.isAuth ?
<div className="flex items-center justify-between sm:hidden">
<p className=" dark:text-white max-w-96">
Пятый пункт в навигации
</p>
<Dropdown
color="blue"
label={
preferenceStore.flags.showFifthButton ?
FifthButton[preferenceStore.flags.showFifthButton]
: "Нет"
}
>
<DropdownItem
onClick={() =>
preferenceStore.setFlags({
showFifthButton: null,
})
}
>
Не показывать
</DropdownItem>
{Object.keys(FifthButton).map((key) => {
return (
<DropdownItem
key={`navbar-fifthbutton-${key}`}
onClick={() =>
preferenceStore.setFlags({
showFifthButton: Number(key) as 3 | 4 | 5,
})
}
>
{FifthButton[key]}
</DropdownItem>
);
})}
</Dropdown>
</div>
: ""}
<HR className="my-4 dark:bg-slate-400" /> <HR className="my-4 dark:bg-slate-400" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-6 h-6 iconify material-symbols--settings-outline"></span> <span className="w-6 h-6 iconify material-symbols--settings-outline"></span>
@ -197,15 +241,6 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
<p className=" dark:text-white">Показывать список изменений</p> <p className=" dark:text-white">Показывать список изменений</p>
<ToggleSwitch <ToggleSwitch
color="blue" color="blue"
theme={{
toggle: {
checked: {
color: {
blue: "border-blue-700 bg-blue-700",
},
},
},
}}
onChange={() => onChange={() =>
preferenceStore.setFlags({ preferenceStore.setFlags({
showChangelog: !preferenceStore.flags.showChangelog, showChangelog: !preferenceStore.flags.showChangelog,
@ -223,15 +258,6 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
</div> </div>
<ToggleSwitch <ToggleSwitch
color="blue" color="blue"
theme={{
toggle: {
checked: {
color: {
blue: "border-blue-700 bg-blue-700",
},
},
},
}}
onChange={() => onChange={() =>
preferenceStore.setFlags({ preferenceStore.setFlags({
enableAnalytics: !preferenceStore.flags.enableAnalytics, enableAnalytics: !preferenceStore.flags.enableAnalytics,
@ -254,15 +280,6 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
</div> </div>
<ToggleSwitch <ToggleSwitch
color="blue" color="blue"
theme={{
toggle: {
checked: {
color: {
blue: "border-blue-700 bg-blue-700",
},
},
},
}}
onChange={() => onChange={() =>
preferenceStore.setParams({ preferenceStore.setParams({
experimental: { experimental: {
@ -278,31 +295,20 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
<HR className="my-4 dark:bg-slate-400" /> <HR className="my-4 dark:bg-slate-400" />
<div> <div>
<Link <Link
href={"https://t.me/anix_web"} href={"/about"}
className="flex items-center gap-2 p-2 text-left rounded-md hover:bg-gray-100 dark:hover:bg-gray-900" className="flex items-center gap-2 p-2 text-left rounded-md hover:bg-gray-100 dark:hover:bg-gray-900"
onClick={() => props.setIsOpen(false)}
> >
<span className="w-8 h-8 iconify fa6-brands--telegram"></span> <span className="w-8 h-8 iconify material-symbols--info"></span>
<div> <div>
<p>Телеграм канал</p> <p>О приложении</p>
<p className="text-sm text-gray-400 dark:text-gray-200"> <p className="text-sm text-gray-400 dark:text-gray-200">
@anix_web v{CURRENT_APP_VERSION}
</p>
</div>
</Link>
<Link
href={"https://wah.su/radiquum"}
className="flex items-center gap-2 p-2 text-left rounded-md hover:bg-gray-100 dark:hover:bg-gray-900"
>
<span className="w-8 h-8 iconify mdi--code"></span>
<div>
<p>Разработчик</p>
<p className="text-sm text-gray-400 dark:text-gray-200">
Radiquum
</p> </p>
</div> </div>
</Link> </Link>
</div> </div>
</Modal.Body> </ModalBody>
</Modal> </Modal>
); );
}; };

View file

@ -17,7 +17,7 @@ export const UserSection = (props: { sectionTitle?: string; content: any }) => {
return ( 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"> <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"> <Card className="items-center justify-center w-full h-full">
<Avatar img={user.avatar} alt={user.login} size="lg" rounded={true} /> <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"> <h5 className="mb-1 text-xl font-medium text-gray-900 dark:text-white">
{user.login} {user.login}
</h5> </h5>

135
app/pages/About.tsx Normal file
View file

@ -0,0 +1,135 @@
"use server";
import { Card } from "flowbite-react";
import Image from "next/image";
import * as fs from "node:fs";
import * as path from "node:path";
import { CURRENT_APP_VERSION } from "#/api/config";
import Styles from "../components/ChangelogModal/ChangelogModal.module.css";
import Markdown from "markdown-to-jsx";
import {
Accordion,
AccordionContent,
AccordionPanel,
AccordionTitle,
} from "flowbite-react";
import Link from "next/link";
export const AboutPage = () => {
const directoryPath = path.join(process.cwd(), "public/changelog");
const files = fs.readdirSync(directoryPath);
const current = {
version: CURRENT_APP_VERSION,
changelog: `#${CURRENT_APP_VERSION}\r\nНет списка изменений`,
};
const previous = [];
if (files.includes(`${CURRENT_APP_VERSION}.md`)) {
const changelog = fs.readFileSync(
path.join(directoryPath, `${CURRENT_APP_VERSION}.md`),
"utf8"
);
current.changelog = changelog;
}
files.forEach((file) => {
if (file != `${CURRENT_APP_VERSION}.md`) {
const changelog = fs.readFileSync(path.join(directoryPath, file), "utf8");
previous.push({
version: file.replace(".md", ""),
changelog: changelog,
});
}
});
return (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
<Card className="md:col-span-2 lg:col-span-3">
<div className="flex flex-col items-center gap-4 md:flex-row">
<Image
src="/images/icons/icon-512x512.png"
className="flex-shrink-0 w-32 h-32 rounded-full"
alt="about image"
width={128}
height={128}
/>
<div>
<h1 className="text-xl font-bold">
AniX - Неофициальный веб клиент для Anixart
</h1>
<p className="max-w-[900px]">
AniX - это неофициальный веб-клиент для Android-приложения
Anixart. Он позволяет вам получать доступ к своей учетной записи
Anixart и управлять ею из веб-браузера. Так-же можно
синхронизировать и управлять списками и избранным. И самое главное
смотреть все доступные аниме из базы Anixart.
</p>
</div>
</div>
</Card>
<Link href={"https://wah.su/radiquum"} target="_blank">
<Card>
<div className="flex items-center gap-4">
<Image
src="https://radiquum.wah.su/static/avatar_512.jpg"
className="flex-shrink-0 w-16 h-16 rounded-full"
alt="developer image"
width={128}
height={128}
/>
<div>
<h1 className="text-xl font-bold">Radiquum</h1>
<p className="text-sm text-gray-500 dark:text-gray-200">
Разработчик
</p>
</div>
</div>
</Card>
</Link>
<Link href={"https://t.me/anix_web"} target="_blank">
<Card>
<div className="flex items-center gap-4">
<span className="w-16 h-16 iconify fa6-brands--telegram text-[#001725] dark:text-[#faf8f9]"></span>
<div>
<h1 className="text-xl font-bold">Телеграм канал</h1>
<p className="text-sm text-gray-500 dark:text-gray-200">
@anix_web
</p>
</div>
</div>
</Card>
</Link>
<Link href={"https://github.com/Radiquum/AniX"} target="_blank">
<Card>
<div className="flex items-center gap-4">
<span className="flex-shrink-0 w-16 h-16 iconify fa6-brands--github text-[#001725] dark:text-[#faf8f9]"></span>
<div>
<h1 className="text-xl font-bold">Код на GitHub</h1>
<p className="text-sm text-gray-500 dark:text-gray-200">
github.com/Radiquum/AniX
</p>
</div>
</div>
</Card>
</Link>
<Card className="md:col-span-2 lg:col-span-3">
<h1 className="text-2xl font-bold">Список изменений</h1>
<Markdown className={Styles.markdown}>{current.changelog}</Markdown>
<Accordion collapseAll={true}>
{previous.reverse().map((changelog) => (
<AccordionPanel key={changelog.version}>
<AccordionTitle>v{changelog.version}</AccordionTitle>
<AccordionContent>
<Markdown className={Styles.markdown}>
{changelog.changelog}
</Markdown>
</AccordionContent>
</AccordionPanel>
))}
</Accordion>
</Card>
</div>
);
};

View file

@ -2,11 +2,9 @@
import useSWR from "swr"; import useSWR from "swr";
import { ReleaseCourusel } from "#/components/ReleaseCourusel/ReleaseCourusel"; import { ReleaseCourusel } from "#/components/ReleaseCourusel/ReleaseCourusel";
import { Spinner } from "#/components/Spinner/Spinner"; import { Spinner } from "#/components/Spinner/Spinner";
const fetcher = (...args: any) =>
fetch([...args] as any).then((res) => res.json());
import { useUserStore } from "#/store/auth"; import { useUserStore } from "#/store/auth";
import { usePreferencesStore } from "#/store/preferences"; import { usePreferencesStore } from "#/store/preferences";
import { BookmarksList } from "#/api/utils"; import { BookmarksList, useSWRfetcher } from "#/api/utils";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
@ -35,7 +33,7 @@ export function BookmarksPage(props: { profile_id?: number }) {
function useFetchReleases(listName: string) { function useFetchReleases(listName: string) {
let url: string; let url: string;
if (preferenceStore.params.skipToCategory.enabled) { if (preferenceStore.params.skipToCategory.enabled) {
return [null]; return [null, null];
} }
if (props.profile_id) { if (props.profile_id) {
@ -50,8 +48,8 @@ export function BookmarksPage(props: { profile_id?: number }) {
} }
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const { data } = useSWR(url, fetcher); const { data, error } = useSWR(url, useSWRfetcher);
return [data]; return [data, error];
} }
useEffect(() => { useEffect(() => {
@ -61,11 +59,11 @@ export function BookmarksPage(props: { profile_id?: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [authState, token]); }, [authState, token]);
const [watchingData] = useFetchReleases("watching"); const [watchingData, watchingError] = useFetchReleases("watching");
const [plannedData] = useFetchReleases("planned"); const [plannedData, plannedError] = useFetchReleases("planned");
const [watchedData] = useFetchReleases("watched"); const [watchedData, watchedError] = useFetchReleases("watched");
const [delayedData] = useFetchReleases("delayed"); const [delayedData, delayedError] = useFetchReleases("delayed");
const [abandonedData] = useFetchReleases("abandoned"); const [abandonedData, abandonedError] = useFetchReleases("abandoned");
return ( return (
<> <>
@ -85,9 +83,9 @@ export function BookmarksPage(props: { profile_id?: number }) {
<ReleaseCourusel <ReleaseCourusel
sectionTitle="Смотрю" sectionTitle="Смотрю"
showAllLink={ showAllLink={
!props.profile_id !props.profile_id ?
? "/bookmarks/watching" "/bookmarks/watching"
: `/profile/${props.profile_id}/bookmarks/watching` : `/profile/${props.profile_id}/bookmarks/watching`
} }
content={watchingData.content} content={watchingData.content}
/> />
@ -96,9 +94,9 @@ export function BookmarksPage(props: { profile_id?: number }) {
<ReleaseCourusel <ReleaseCourusel
sectionTitle="В планах" sectionTitle="В планах"
showAllLink={ showAllLink={
!props.profile_id !props.profile_id ? "/bookmarks/planned" : (
? "/bookmarks/planned" `/profile/${props.profile_id}/bookmarks/planned`
: `/profile/${props.profile_id}/bookmarks/planned` )
} }
content={plannedData.content} content={plannedData.content}
/> />
@ -107,9 +105,9 @@ export function BookmarksPage(props: { profile_id?: number }) {
<ReleaseCourusel <ReleaseCourusel
sectionTitle="Просмотрено" sectionTitle="Просмотрено"
showAllLink={ showAllLink={
!props.profile_id !props.profile_id ? "/bookmarks/watched" : (
? "/bookmarks/watched" `/profile/${props.profile_id}/bookmarks/watched`
: `/profile/${props.profile_id}/bookmarks/watched` )
} }
content={watchedData.content} content={watchedData.content}
/> />
@ -118,9 +116,9 @@ export function BookmarksPage(props: { profile_id?: number }) {
<ReleaseCourusel <ReleaseCourusel
sectionTitle="Отложено" sectionTitle="Отложено"
showAllLink={ showAllLink={
!props.profile_id !props.profile_id ? "/bookmarks/delayed" : (
? "/bookmarks/delayed" `/profile/${props.profile_id}/bookmarks/delayed`
: `/profile/${props.profile_id}/bookmarks/delayed` )
} }
content={delayedData.content} content={delayedData.content}
/> />
@ -131,13 +129,28 @@ export function BookmarksPage(props: { profile_id?: number }) {
<ReleaseCourusel <ReleaseCourusel
sectionTitle="Заброшено" sectionTitle="Заброшено"
showAllLink={ showAllLink={
!props.profile_id !props.profile_id ?
? "/bookmarks/abandoned" "/bookmarks/abandoned"
: `/profile/${props.profile_id}/bookmarks/abandoned` : `/profile/${props.profile_id}/bookmarks/abandoned`
} }
content={abandonedData.content} content={abandonedData.content}
/> />
)} )}
{(watchingError ||
plannedError ||
watchedError ||
delayedError ||
abandonedError) && (
<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>
)}
</> </>
); );
} }

View file

@ -5,10 +5,10 @@ import { Spinner } from "#/components/Spinner/Spinner";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useScrollPosition } from "#/hooks/useScrollPosition"; import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "../store/auth"; import { useUserStore } from "../store/auth";
import { Dropdown, Button, Tabs } from "flowbite-react"; import { Button, ButtonGroup, Dropdown, DropdownItem } from "flowbite-react";
import { sort } from "./common"; import { sort } from "./common";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { BookmarksList } from "#/api/utils"; import { BookmarksList, useSWRfetcher } from "#/api/utils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
const DropdownTheme = { const DropdownTheme = {
@ -17,25 +17,10 @@ const DropdownTheme = {
}, },
}; };
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
export function BookmarksCategoryPage(props: any) { export function BookmarksCategoryPage(props: any) {
const token = useUserStore((state) => state.token); const token = useUserStore((state) => state.token);
const authState = useUserStore((state) => state.state); const authState = useUserStore((state) => state.state);
const [selectedSort, setSelectedSort] = useState(0); const [selectedSort, setSelectedSort] = useState(0);
const [isLoadingEnd, setIsLoadingEnd] = useState(false);
const [searchVal, setSearchVal] = useState(""); const [searchVal, setSearchVal] = useState("");
const router = useRouter(); const router = useRouter();
@ -61,7 +46,7 @@ export function BookmarksCategoryPage(props: any) {
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2 } { initialSize: 2 }
); );
@ -73,7 +58,6 @@ export function BookmarksCategoryPage(props: any) {
allReleases.push(...data[i].content); allReleases.push(...data[i].content);
} }
setContent(allReleases); setContent(allReleases);
setIsLoadingEnd(true);
} }
}, [data]); }, [data]);
@ -92,9 +76,31 @@ export function BookmarksCategoryPage(props: any) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [authState, token]); }, [authState, token]);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-w-full min-h-screen">
<Spinner />
</div>
);
};
if (error) {
return (
<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>
);
}
return ( return (
<> <>
{!props.profile_id ? ( {!props.profile_id ?
<form <form
className="flex-1 max-w-full mx-4" className="flex-1 max-w-full mx-4"
onSubmit={(e) => { onSubmit={(e) => {
@ -143,20 +149,18 @@ export function BookmarksCategoryPage(props: any) {
</button> </button>
</div> </div>
</form> </form>
) : ( : ""}
""
)}
<div className="m-4 overflow-auto"> <div className="m-4 overflow-auto">
<Button.Group> <ButtonGroup>
<Button <Button
className="whitespace-nowrap" className="whitespace-nowrap"
disabled={props.slug == "watching"} disabled={props.slug == "watching"}
color="light" color="light"
onClick={() => onClick={() =>
router.push( router.push(
props.profile_id props.profile_id ?
? `/profile/${props.profile_id}/bookmarks/watching` `/profile/${props.profile_id}/bookmarks/watching`
: "/bookmarks/watching" : "/bookmarks/watching"
) )
} }
> >
@ -168,9 +172,9 @@ export function BookmarksCategoryPage(props: any) {
color="light" color="light"
onClick={() => onClick={() =>
router.push( router.push(
props.profile_id props.profile_id ?
? `/profile/${props.profile_id}/bookmarks/planned` `/profile/${props.profile_id}/bookmarks/planned`
: "/bookmarks/planned" : "/bookmarks/planned"
) )
} }
> >
@ -182,9 +186,9 @@ export function BookmarksCategoryPage(props: any) {
color="light" color="light"
onClick={() => onClick={() =>
router.push( router.push(
props.profile_id props.profile_id ?
? `/profile/${props.profile_id}/bookmarks/watched` `/profile/${props.profile_id}/bookmarks/watched`
: "/bookmarks/watched" : "/bookmarks/watched"
) )
} }
> >
@ -196,9 +200,9 @@ export function BookmarksCategoryPage(props: any) {
color="light" color="light"
onClick={() => onClick={() =>
router.push( router.push(
props.profile_id props.profile_id ?
? `/profile/${props.profile_id}/bookmarks/delayed` `/profile/${props.profile_id}/bookmarks/delayed`
: "/bookmarks/delayed" : "/bookmarks/delayed"
) )
} }
> >
@ -210,15 +214,15 @@ export function BookmarksCategoryPage(props: any) {
color="light" color="light"
onClick={() => onClick={() =>
router.push( router.push(
props.profile_id props.profile_id ?
? `/profile/${props.profile_id}/bookmarks/abandoned` `/profile/${props.profile_id}/bookmarks/abandoned`
: "/bookmarks/abandoned" : "/bookmarks/abandoned"
) )
} }
> >
{props.SectionTitleMapping["abandoned"]} {props.SectionTitleMapping["abandoned"]}
</Button> </Button>
</Button.Group> </ButtonGroup>
</div> </div>
<div className="flex items-center justify-between px-4 py-2 border-b-2 border-black dark:border-white"> <div className="flex items-center 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 className="font-bold text-md sm:text-xl md:text-lg xl:text-xl">
@ -233,33 +237,28 @@ export function BookmarksCategoryPage(props: any) {
theme={DropdownTheme} theme={DropdownTheme}
> >
{sort.values.map((item, index) => ( {sort.values.map((item, index) => (
<Dropdown.Item key={index} onClick={() => setSelectedSort(index)}> <DropdownItem key={index} onClick={() => setSelectedSort(index)}>
<span <span
className={`w-6 h-6 iconify ${ className={`w-6 h-6 iconify ${
sort.values[index].value.split("_")[1] == "descending" sort.values[index].value.split("_")[1] == "descending" ?
? sort.descendingIcon sort.descendingIcon
: sort.ascendingIcon : sort.ascendingIcon
}`} }`}
></span> ></span>
{item.name} {item.name}
</Dropdown.Item> </DropdownItem>
))} ))}
</Dropdown> </Dropdown>
</div> </div>
{content && content.length > 0 ? ( {content && content.length > 0 ?
<ReleaseSection content={content} /> <ReleaseSection content={content} />
) : !isLoadingEnd || isLoading ? ( : <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 min-h-screen">
<Spinner />
</div>
) : (
<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> <span className="w-24 h-24 iconify-color twemoji--broken-heart"></span>
<p> <p>
В списке {props.SectionTitleMapping[props.slug]} пока ничего нет... В списке {props.SectionTitleMapping[props.slug]} пока ничего нет...
</p> </p>
</div> </div>
)} }
{data && {data &&
data[data.length - 1].current_page < data[data.length - 1].current_page <
data[data.length - 1].total_page_count && ( data[data.length - 1].total_page_count && (

View file

@ -2,8 +2,7 @@
import useSWR from "swr"; import useSWR from "swr";
import { CollectionCourusel } from "#/components/CollectionCourusel/CollectionCourusel"; import { CollectionCourusel } from "#/components/CollectionCourusel/CollectionCourusel";
import { Spinner } from "#/components/Spinner/Spinner"; import { Spinner } from "#/components/Spinner/Spinner";
const fetcher = (...args: any) => import { useSWRfetcher } from "#/api/utils";
fetch([...args] as any).then((res) => res.json());
import { useUserStore } from "#/store/auth"; import { useUserStore } from "#/store/auth";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -25,12 +24,15 @@ export function CollectionsPage() {
} }
} }
const { data } = useSWR(url, fetcher); const { data, error } = useSWR(url, useSWRfetcher);
return [data]; return [data, error];
} }
const [userCollections] = useFetchReleases("userCollections"); const [userCollections, userCollectionsError] =
const [favoriteCollections] = useFetchReleases("userFavoriteCollections"); useFetchReleases("userCollections");
const [favoriteCollections, favoriteCollectionsError] = useFetchReleases(
"userFavoriteCollections"
);
useEffect(() => { useEffect(() => {
if (userStore.state === "finished" && !userStore.token) { if (userStore.state === "finished" && !userStore.token) {
@ -114,6 +116,18 @@ export function CollectionsPage() {
content={favoriteCollections.content} content={favoriteCollections.content}
/> />
)} )}
{(userCollectionsError || favoriteCollectionsError) && (
<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>
)}
</> </>
); );
} }

View file

@ -8,20 +8,7 @@ import { useUserStore } from "../store/auth";
import { Button } from "flowbite-react"; import { Button } from "flowbite-react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSWRfetcher } from "#/api/utils";
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
export function CollectionsFullPage(props: { export function CollectionsFullPage(props: {
type: "favorites" | "profile" | "release"; type: "favorites" | "profile" | "release";
@ -30,13 +17,12 @@ export function CollectionsFullPage(props: {
release_id?: number; release_id?: number;
}) { }) {
const userStore = useUserStore(); const userStore = useUserStore();
const [isLoadingEnd, setIsLoadingEnd] = useState(false);
const router = useRouter(); const router = useRouter();
const getKey = (pageIndex: number, previousPageData: any) => { const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.content.length) return null; if (previousPageData && !previousPageData.content.length) return null;
let url; let url: string;
if (props.type == "favorites") { if (props.type == "favorites") {
url = `${ENDPOINTS.collection.favoriteCollections}/all/${pageIndex}`; url = `${ENDPOINTS.collection.favoriteCollections}/all/${pageIndex}`;
@ -55,7 +41,7 @@ export function CollectionsFullPage(props: {
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2 } { initialSize: 2 }
); );
@ -67,7 +53,6 @@ export function CollectionsFullPage(props: {
allReleases.push(...data[i].content); allReleases.push(...data[i].content);
} }
setContent(allReleases); setContent(allReleases);
setIsLoadingEnd(true);
} }
}, [data]); }, [data]);
@ -90,26 +75,45 @@ export function CollectionsFullPage(props: {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userStore.state, userStore.token]); }, [userStore.state, userStore.token]);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-w-full min-h-screen">
<Spinner />
</div>
);
};
if (error) {
return (
<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>
);
};
return ( return (
<> <>
{content && content.length > 0 ? ( {content && content.length > 0 ?
<CollectionsSection <CollectionsSection
sectionTitle={props.title} sectionTitle={props.title}
content={content} content={content}
isMyCollections={ isMyCollections={
props.type == "profile" && userStore.user && props.profile_id == userStore.user.id props.type == "profile" &&
userStore.user &&
props.profile_id == userStore.user.id
} }
/> />
) : !isLoadingEnd || isLoading ? ( : <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 min-h-screen">
<Spinner />
</div>
) : (
<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> <span className="w-24 h-24 iconify-color twemoji--broken-heart"></span>
<p>Тут пока ничего нет...</p> <p>Тут пока ничего нет...</p>
</div> </div>
)} }
{data && {data &&
data[data.length - 1].current_page < data[data.length - 1].current_page <
data[data.length - 1].total_page_count && ( data[data.length - 1].total_page_count && (

View file

@ -5,42 +5,40 @@ import { useEffect, useState, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { import {
Card,
Button, Button,
Card,
Checkbox, Checkbox,
TextInput,
Textarea,
FileInput, FileInput,
Label, Label,
Modal, Modal,
ModalHeader,
Textarea,
TextInput,
useThemeMode,
} from "flowbite-react"; } from "flowbite-react";
import { ReleaseLink } from "#/components/ReleaseLink/ReleaseLink"; import { PosterWithStuff } from "#/components/ReleasePoster/PosterWithStuff";
import { CropModal } from "#/components/CropModal/CropModal"; import { CropModal } from "#/components/CropModal/CropModal";
import { b64toBlob } from "#/api/utils"; import { b64toBlob, tryCatchAPI } from "#/api/utils";
const fetcher = async (url: string) => { import { useSWRfetcher } from "#/api/utils";
const res = await fetch(url); import { Spinner } from "#/components/Spinner/Spinner";
import { toast } from "react-toastify";
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
export const CreateCollectionPage = () => { export const CreateCollectionPage = () => {
const userStore = useUserStore(); const userStore = useUserStore();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const theme = useThemeMode();
useEffect(() => {
if (userStore.state === "finished" && !userStore.token) {
router.push("/login?redirect=/collections/create");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userStore]);
const [edit, setEdit] = useState(false); const [edit, setEdit] = useState(false);
const [imageUrl, setImageUrl] = useState<string>(null);
const [tempImageUrl, setTempImageUrl] = useState<string>(null);
const [isPrivate, setIsPrivate] = useState(false); const [isPrivate, setIsPrivate] = useState(false);
const [collectionInfo, setCollectionInfo] = useState({ const [collectionInfo, setCollectionInfo] = useState({
title: "", title: "",
@ -53,7 +51,14 @@ export const CreateCollectionPage = () => {
const [addedReleases, setAddedReleases] = useState([]); const [addedReleases, setAddedReleases] = useState([]);
const [addedReleasesIds, setAddedReleasesIds] = useState([]); const [addedReleasesIds, setAddedReleasesIds] = useState([]);
const [releasesEditModalOpen, setReleasesEditModalOpen] = useState(false); const [releasesEditModalOpen, setReleasesEditModalOpen] = useState(false);
const [cropModalOpen, setCropModalOpen] = useState(false);
const [imageModalProps, setImageModalProps] = useState({
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
});
const [imageUrl, setImageUrl] = useState<string>(null);
const collection_id = searchParams.get("id") || null; const collection_id = searchParams.get("id") || null;
const mode = searchParams.get("mode") || null; const mode = searchParams.get("mode") || null;
@ -120,15 +125,29 @@ export const CreateCollectionPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userStore.user]); }, [userStore.user]);
const handleFileRead = (e, fileReader) => { useEffect(() => {
const content = fileReader.result; if (imageModalProps.croppedImage) {
setTempImageUrl(content); setImageUrl(imageModalProps.croppedImage);
}; setImageModalProps({
isOpen: false,
isActionsDisabled: false,
selectedImage: null,
croppedImage: null,
});
}
}, [imageModalProps.croppedImage]);
const handleFilePreview = (file) => { const handleImagePreview = (e: any) => {
const file = e.target.files[0];
const fileReader = new FileReader(); const fileReader = new FileReader();
fileReader.onloadend = (e) => { fileReader.onloadend = () => {
handleFileRead(e, fileReader); const content = fileReader.result;
setImageModalProps({
...imageModalProps,
isOpen: true,
selectedImage: content,
});
e.target.value = "";
}; };
fileReader.readAsDataURL(file); fileReader.readAsDataURL(file);
}; };
@ -149,25 +168,50 @@ export const CreateCollectionPage = () => {
e.preventDefault(); e.preventDefault();
async function _createCollection() { async function _createCollection() {
setIsSending(true);
const tid = toast.loading(
mode === "edit" ? "Редактируем коллекцию..." : "Создаём коллекцию...",
{
position: "bottom-center",
hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
}
);
const url = const url =
mode === "edit" mode === "edit" ?
? `${ENDPOINTS.collection.edit}/${collection_id}?token=${userStore.token}` `${ENDPOINTS.collection.edit}/${collection_id}?token=${userStore.token}`
: `${ENDPOINTS.collection.create}?token=${userStore.token}`; : `${ENDPOINTS.collection.create}?token=${userStore.token}`;
const res = await fetch(url, { const { data, error } = await tryCatchAPI(
method: "POST", fetch(url, {
body: JSON.stringify({ method: "POST",
...collectionInfo, body: JSON.stringify({
is_private: isPrivate, ...collectionInfo,
private: isPrivate, is_private: isPrivate,
releases: addedReleasesIds, private: isPrivate,
}), releases: addedReleasesIds,
}); }),
})
);
const data = await res.json(); if (error) {
let message = `${error.message}, code: ${error.code}`;
if (data.code == 5) { if (error.code == 5) {
alert("Вы превысили допустимый еженедельный лимит создания коллекций!"); message =
"Вы превысили допустимый еженедельный лимит создания коллекций";
}
toast.update(tid, {
render: message,
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setIsSending(false);
return; return;
} }
@ -180,33 +224,92 @@ export const CreateCollectionPage = () => {
const formData = new FormData(); const formData = new FormData();
formData.append("image", blob, "cropped.jpg"); formData.append("image", blob, "cropped.jpg");
formData.append("name", "image"); formData.append("name", "image");
const uploadRes = await fetch(
`${ENDPOINTS.collection.editImage}/${data.collection.id}?token=${userStore.token}`, const tiid = toast.loading(
`Обновление обложки коллекции ${collectionInfo.title}...`,
{ {
method: "POST", position: "bottom-center",
body: formData, hideProgressBar: true,
closeOnClick: false,
pauseOnHover: false,
draggable: false,
theme: theme.mode == "light" ? "light" : "dark",
} }
); );
const uploadData = await uploadRes.json();
const { data: imageData, error } = await tryCatchAPI(
fetch(
`${ENDPOINTS.collection.editImage}/${data.collection.id}?token=${userStore.token}`,
{
method: "POST",
body: formData,
}
)
);
if (error) {
toast.update(tiid, {
render: "Не удалось обновить постер коллекции",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
} else {
toast.update(tiid, {
render: "Постер коллекции обновлён",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
}
} }
toast.update(tid, {
render:
mode === "edit" ?
`Коллекция ${collectionInfo.title} обновлена`
: `Коллекция ${collectionInfo.title} создана`,
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
router.push(`/collection/${data.collection.id}`); router.push(`/collection/${data.collection.id}`);
setIsSending(false);
} }
if ( if (collectionInfo.title.length < 10) {
collectionInfo.title.length >= 10 && toast.error("Необходимо ввести название коллекции не менее 10 символов", {
addedReleasesIds.length >= 1 && position: "bottom-center",
userStore.token hideProgressBar: true,
) { type: "error",
// setIsSending(true); autoClose: 2500,
_createCollection(); isLoading: false,
} else if (collectionInfo.title.length < 10) { closeOnClick: true,
alert("Необходимо ввести название коллекции не менее 10 символов"); draggable: true,
} else if (!userStore.token) { });
alert("Для создания коллекции необходимо войти в аккаунт"); return;
} else if (addedReleasesIds.length < 1) {
alert("Необходимо добавить хотя бы один релиз в коллекцию");
} }
if (addedReleasesIds.length < 1) {
toast.error("Необходимо добавить хотя бы один релиз в коллекцию", {
position: "bottom-center",
hideProgressBar: true,
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
return;
}
_createCollection();
} }
function _deleteRelease(release: any) { function _deleteRelease(release: any) {
@ -239,56 +342,57 @@ export const CreateCollectionPage = () => {
className="flex flex-col items-center w-full sm:max-w-[600px] h-[337px] border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-600" className="flex flex-col items-center w-full sm:max-w-[600px] h-[337px] border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-600"
> >
<div className="flex flex-col items-center justify-center max-w-[595px] h-[inherit] rounded-[inherit] pt-5 pb-6 overflow-hidden"> <div className="flex flex-col items-center justify-center max-w-[595px] h-[inherit] rounded-[inherit] pt-5 pb-6 overflow-hidden">
{!imageUrl ? ( {
<> !imageUrl ?
<svg <>
className="w-8 h-8 mb-4 text-gray-500 dark:text-gray-400" <svg
aria-hidden="true" className="w-8 h-8 mb-4 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true"
fill="none" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 16" fill="none"
> viewBox="0 0 20 16"
<path >
stroke="currentColor" <path
strokeLinecap="round" stroke="currentColor"
strokeLinejoin="round" strokeLinecap="round"
strokeWidth="2" strokeLinejoin="round"
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2" strokeWidth="2"
/> d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
</svg> />
<p className="mb-2 text-sm text-gray-500 dark:text-gray-400"> </svg>
<span className="font-semibold">Нажмите для загрузки</span>{" "} <p className="mb-2 text-sm text-gray-500 dark:text-gray-400">
или перетащите файл <span className="font-semibold">
</p> Нажмите для загрузки
<p className="text-xs text-gray-500 dark:text-gray-400"> </span>{" "}
PNG или JPG (Макс. 600x337 пикселей) или перетащите файл
</p> </p>
</> <p className="text-xs text-gray-500 dark:text-gray-400">
) : ( PNG или JPG (Макс. 600x337 пикселей)
// eslint-disable-next-line @next/next/no-img-element </p>
<img </>
src={imageUrl} // eslint-disable-next-line @next/next/no-img-element
className="object-cover w-[inherit] h-[inherit]" : <img
alt="" src={imageUrl}
/> className="object-cover w-[inherit] h-[inherit]"
)} alt=""
/>
}
</div> </div>
<FileInput <FileInput
id="dropzone-file" id="dropzone-file"
className="hidden" className="hidden"
accept="image/jpg, image/jpeg, image/png" accept="image/jpg, image/jpeg, image/png"
onChange={(e) => { onChange={(e) => {
handleFilePreview(e.target.files[0]); handleImagePreview(e);
setCropModalOpen(true);
}} }}
/> />
</Label> </Label>
<div className="flex-1"> <div className="flex-1">
<div className="block mb-2"> <div className="block mb-2">
<Label <Label htmlFor="title">
htmlFor="title" Название (минимум 10, максимум 60 символов)
value="Название (минимум 10, максимум 60 символов)" </Label>
/>
</div> </div>
<TextInput <TextInput
id="title" id="title"
@ -305,10 +409,9 @@ export const CreateCollectionPage = () => {
{stringLength.title}/60 {stringLength.title}/60
</p> </p>
<div className="block mt-2 mb-2"> <div className="block mt-2 mb-2">
<Label <Label htmlFor="description">
htmlFor="description" Описание (максимум 1000 символов)
value="Описание (максимум 1000 символов)" </Label>
/>
</div> </div>
<Textarea <Textarea
rows={4} rows={4}
@ -331,7 +434,7 @@ export const CreateCollectionPage = () => {
checked={isPrivate} checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)} onChange={(e) => setIsPrivate(e.target.checked)}
/> />
<Label htmlFor="private" value="Приватная коллекция" /> <Label htmlFor="private">Приватная коллекция</Label>
</div> </div>
</div> </div>
<Button <Button
@ -359,20 +462,17 @@ export const CreateCollectionPage = () => {
</Button> </Button>
</div> </div>
<div className="m-4"> <div className="m-4">
<div className="grid justify-center sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))] grid-cols-[100%] gap-2 min-w-full"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{addedReleases.map((release) => { {addedReleases.map((release) => {
return ( return (
<div <div key={release.id} className="relative w-full h-full group">
key={release.id}
className="relative w-full h-full aspect-video group"
>
<button <button
className="absolute inset-0 z-10 text-black transition-opacity bg-white opacity-0 group-hover:opacity-75" className="absolute inset-0 z-30 text-black transition-opacity bg-white rounded-lg opacity-0 group-hover:opacity-75"
onClick={() => _deleteRelease(release)} onClick={() => _deleteRelease(release)}
> >
Удалить Удалить
</button> </button>
<ReleaseLink {...release} isLinkDisabled={true} /> <PosterWithStuff {...release} />
</div> </div>
); );
})} })}
@ -389,18 +489,15 @@ export const CreateCollectionPage = () => {
setReleasesIds={setAddedReleasesIds} setReleasesIds={setAddedReleasesIds}
/> />
<CropModal <CropModal
src={tempImageUrl} {...imageModalProps}
setSrc={setImageUrl} cropParams={{
setTempSrc={setTempImageUrl} aspectRatio: 600 / 337,
// setImageData={setImageData} forceAspect: true,
aspectRatio={600 / 337} guides: true,
guides={false} width: 600,
quality={100} height: 337,
isOpen={cropModalOpen} }}
setIsOpen={setCropModalOpen} setCropModalProps={setImageModalProps}
forceAspect={true}
width={600}
height={337}
/> />
</> </>
); );
@ -428,7 +525,7 @@ export const ReleasesEditModal = (props: {
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2, revalidateFirstPage: false } { initialSize: 2, revalidateFirstPage: false }
); );
@ -464,15 +561,37 @@ export const ReleasesEditModal = (props: {
function _addRelease(release: any) { function _addRelease(release: any) {
if (props.releasesIds.length == 100) { if (props.releasesIds.length == 100) {
alert("Достигнуто максимальное количество релизов в коллекции - 100"); toast.error(
"Достигнуто максимальное количество релизов в коллекции - 100",
{
position: "bottom-center",
hideProgressBar: true,
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
}
);
return; return;
} }
if (props.releasesIds.includes(release.id)) { if (props.releasesIds.includes(release.id)) {
alert("Релиз уже добавлен в коллекцию"); toast.error("Релиз уже добавлен в коллекцию", {
position: "bottom-center",
hideProgressBar: true,
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
return; return;
} }
const newContent = content.filter((r) => r.id !== release.id);
setContent(newContent);
props.setReleases([...props.releases, release]); props.setReleases([...props.releases, release]);
props.setReleasesIds([...props.releasesIds, release.id]); props.setReleasesIds([...props.releasesIds, release.id]);
} }
@ -484,7 +603,7 @@ export const ReleasesEditModal = (props: {
onClose={() => props.setIsOpen(false)} onClose={() => props.setIsOpen(false)}
size={"7xl"} size={"7xl"}
> >
<Modal.Header>Изменить релизы в коллекции</Modal.Header> <ModalHeader>Изменить релизы в коллекции</ModalHeader>
<div <div
onScroll={handleScroll} onScroll={handleScroll}
ref={modalRef} ref={modalRef}
@ -492,11 +611,7 @@ export const ReleasesEditModal = (props: {
> >
<form <form
className="max-w-full mx-auto" className="max-w-full mx-auto"
onSubmit={(e) => { onSubmit={(e) => e.preventDefault()}
e.preventDefault();
props.setReleases([]);
setQuery(e.target[0].value.trim());
}}
> >
<label <label
htmlFor="default-search" htmlFor="default-search"
@ -528,31 +643,42 @@ export const ReleasesEditModal = (props: {
className="block w-full p-4 text-sm text-gray-900 border border-gray-300 rounded-lg ps-10 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" className="block w-full p-4 text-sm text-gray-900 border border-gray-300 rounded-lg ps-10 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Поиск аниме..." placeholder="Поиск аниме..."
required required
defaultValue={query || ""} onChange={(e) => setQuery(e.target.value)}
value={query || ""}
/> />
<button <button
type="submit" type="button"
className="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800" className="text-white absolute end-2.5 bottom-2.5 bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
onClick={() => {
setSize(0);
setContent([]);
setQuery("");
}}
> >
Поиск Очистить
</button> </button>
</div> </div>
</form> </form>
<div className="flex flex-wrap gap-1 mt-2"> <div className="grid grid-cols-1 gap-2 mt-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{content.map((release) => { {content.map((release) => {
return ( return (
<button <button
className="text-left"
key={release.id} key={release.id}
className=""
onClick={() => _addRelease(release)} onClick={() => _addRelease(release)}
> >
<ReleaseLink type="poster" {...release} isLinkDisabled={true} /> <PosterWithStuff {...release} />
</button> </button>
); );
})} })}
{content.length == 1 && <div></div>}
</div> </div>
{isLoading && (
<div className="flex items-center justify-center h-full min-h-24">
<Spinner />
</div>
)}
{error && <div>Произошла ошибка</div>}
</div> </div>
</Modal> </Modal>
); );

View file

@ -5,10 +5,11 @@ import { Spinner } from "#/components/Spinner/Spinner";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useScrollPosition } from "#/hooks/useScrollPosition"; import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "../store/auth"; import { useUserStore } from "../store/auth";
import { Dropdown, Button } from "flowbite-react"; import { Button, Dropdown, DropdownItem } from "flowbite-react";
import { sort } from "./common"; import { sort } from "./common";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSWRfetcher } from "#/api/utils";
const DropdownTheme = { const DropdownTheme = {
floating: { floating: {
@ -16,25 +17,10 @@ const DropdownTheme = {
}, },
}; };
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
export function FavoritesPage() { export function FavoritesPage() {
const token = useUserStore((state) => state.token); const token = useUserStore((state) => state.token);
const authState = useUserStore((state) => state.state); const authState = useUserStore((state) => state.state);
const [selectedSort, setSelectedSort] = useState(0); const [selectedSort, setSelectedSort] = useState(0);
const [isLoadingEnd, setIsLoadingEnd] = useState(false);
const router = useRouter(); const router = useRouter();
const [searchVal, setSearchVal] = useState(""); const [searchVal, setSearchVal] = useState("");
@ -47,7 +33,7 @@ export function FavoritesPage() {
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2 } { initialSize: 2 }
); );
@ -59,7 +45,6 @@ export function FavoritesPage() {
allReleases.push(...data[i].content); allReleases.push(...data[i].content);
} }
setContent(allReleases); setContent(allReleases);
setIsLoadingEnd(true);
} }
}, [data]); }, [data]);
@ -141,7 +126,7 @@ export function FavoritesPage() {
theme={DropdownTheme} theme={DropdownTheme}
> >
{sort.values.map((item, index) => ( {sort.values.map((item, index) => (
<Dropdown.Item key={index} onClick={() => setSelectedSort(index)}> <DropdownItem key={index} onClick={() => setSelectedSort(index)}>
<span <span
className={`w-6 h-6 iconify ${ className={`w-6 h-6 iconify ${
sort.values[index].value.split("_")[1] == "descending" sort.values[index].value.split("_")[1] == "descending"
@ -150,13 +135,13 @@ export function FavoritesPage() {
}`} }`}
></span> ></span>
{item.name} {item.name}
</Dropdown.Item> </DropdownItem>
))} ))}
</Dropdown> </Dropdown>
</div> </div>
{content && content.length > 0 ? ( {content && content.length > 0 ? (
<ReleaseSection content={content} /> <ReleaseSection content={content} />
) : !isLoadingEnd || isLoading ? ( ) : isLoading ? (
<div className="flex flex-col items-center justify-center min-w-full min-h-screen"> <div className="flex flex-col items-center justify-center min-w-full min-h-screen">
<Spinner /> <Spinner />
</div> </div>

View file

@ -8,25 +8,12 @@ import { useUserStore } from "../store/auth";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { Button } from "flowbite-react"; import { Button } from "flowbite-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSWRfetcher } from "#/api/utils";
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
export function HistoryPage() { export function HistoryPage() {
const token = useUserStore((state) => state.token); const token = useUserStore((state) => state.token);
const authState = useUserStore((state) => state.state); const authState = useUserStore((state) => state.state);
const [isLoadingEnd, setIsLoadingEnd] = useState(false);
const router = useRouter(); const router = useRouter();
const [searchVal, setSearchVal] = useState(""); const [searchVal, setSearchVal] = useState("");
@ -39,7 +26,7 @@ export function HistoryPage() {
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2 } { initialSize: 2 }
); );
@ -51,7 +38,6 @@ export function HistoryPage() {
allReleases.push(...data[i].content); allReleases.push(...data[i].content);
} }
setContent(allReleases); setContent(allReleases);
setIsLoadingEnd(true);
} }
}, [data]); }, [data]);
@ -136,7 +122,7 @@ export function HistoryPage() {
</Button> </Button>
)} )}
</> </>
) : !isLoadingEnd || isLoading ? ( ) : isLoading ? (
<div className="flex flex-col items-center justify-center min-w-full min-h-[100dvh]"> <div className="flex flex-col items-center justify-center min-w-full min-h-[100dvh]">
<Spinner /> <Spinner />
</div> </div>

View file

@ -5,7 +5,7 @@ import { useState, useEffect } from "react";
import { useScrollPosition } from "#/hooks/useScrollPosition"; import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "../store/auth"; import { useUserStore } from "../store/auth";
import { _FetchHomePageReleases } from "#/api/utils"; import { _FetchHomePageReleases } from "#/api/utils";
import { Button } from "flowbite-react"; import { Button, ButtonGroup } from "flowbite-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export function IndexCategoryPage(props) { export function IndexCategoryPage(props) {
@ -53,13 +53,13 @@ export function IndexCategoryPage(props) {
return ( return (
<> <>
<div className="mb-4 overflow-auto"> <div className="mb-4 overflow-auto">
<Button.Group> <ButtonGroup>
<Button className="whitespace-nowrap" disabled={props.slug == "last"} color="light" onClick={() => router.push("/home/last")}>{props.SectionTitleMapping["last"]}</Button> <Button className="whitespace-nowrap" disabled={props.slug == "last"} color="light" onClick={() => router.push("/home/last")}>{props.SectionTitleMapping["last"]}</Button>
<Button className="whitespace-nowrap" disabled={props.slug == "finished"} color="light" onClick={() => router.push("/home/finished")}>{props.SectionTitleMapping["finished"]}</Button> <Button className="whitespace-nowrap" disabled={props.slug == "finished"} color="light" onClick={() => router.push("/home/finished")}>{props.SectionTitleMapping["finished"]}</Button>
<Button className="whitespace-nowrap" disabled={props.slug == "ongoing"} color="light" onClick={() => router.push("/home/ongoing")}>{props.SectionTitleMapping["ongoing"]}</Button> <Button className="whitespace-nowrap" disabled={props.slug == "ongoing"} color="light" onClick={() => router.push("/home/ongoing")}>{props.SectionTitleMapping["ongoing"]}</Button>
<Button className="whitespace-nowrap" disabled={props.slug == "announce"} color="light" onClick={() => router.push("/home/announce")}>{props.SectionTitleMapping["announce"]}</Button> <Button className="whitespace-nowrap" disabled={props.slug == "announce"} color="light" onClick={() => router.push("/home/announce")}>{props.SectionTitleMapping["announce"]}</Button>
<Button className="whitespace-nowrap" disabled={props.slug == "films"} color="light" onClick={() => router.push("/home/films")}>{props.SectionTitleMapping["films"]}</Button> <Button className="whitespace-nowrap" disabled={props.slug == "films"} color="light" onClick={() => router.push("/home/films")}>{props.SectionTitleMapping["films"]}</Button>
</Button.Group> </ButtonGroup>
</div> </div>
{content && content.length > 0 ? ( {content && content.length > 0 ? (
<ReleaseSection <ReleaseSection

View file

@ -1,8 +1,11 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useUserStore } from "#/store/auth"; import { useUserStore } from "#/store/auth";
import { setJWT } from "#/api/utils"; import { setJWT, tryCatchAPI } from "#/api/utils";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useThemeMode } from "flowbite-react";
import { toast } from "react-toastify";
import { ENDPOINTS } from "#/api/config";
export function LoginPage() { export function LoginPage() {
const [login, setLogin] = useState(""); const [login, setLogin] = useState("");
@ -12,37 +15,78 @@ export function LoginPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || null; const redirect = searchParams.get("redirect") || null;
const theme = useThemeMode();
const [isSending, setIsSending] = useState(false);
function submit(e) { async function submit(e) {
e.preventDefault(); e.preventDefault();
fetch("/api/profile/login", { setIsSending(true);
method: "POST",
headers: { const tid = toast.loading("Выполняем вход...", {
"Content-Type": "application/json", position: "bottom-center",
}, hideProgressBar: true,
body: JSON.stringify({ closeOnClick: false,
login: login, pauseOnHover: false,
password: password, draggable: false,
}), theme: theme.mode == "light" ? "light" : "dark",
}) });
.then((response) => {
if (response.ok) { const { data, error } = await tryCatchAPI(
return response.json(); fetch(`${ENDPOINTS.user.auth}?login=${login}&password=${password}`, {
} else { method: "POST",
alert("Ошибка получения пользователя."); headers: {
} Sign: "9aa5c7af74e8cd70c86f7f9587bde23d",
"Content-Type": "application/x-www-form-urlencoded",
},
}) })
.then((data) => { );
if (data.profileToken) {
userStore.login(data.profile, data.profileToken.token); if (error) {
if (remember) { let message = `Ошибка получения пользователя, code: ${error.code}`
setJWT(data.profile.id, data.profileToken.token); if (error.code == 2) {
} message = "Такого пользователя не существует"
router.push("/"); }
} else { if (error.code == 3) {
alert("Неверные данные."); message = "Неправильно указан логин и/или пароль"
} }
toast.update(tid, {
render: message,
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
}); });
setIsSending(false);
return;
}
if (!data.profileToken) {
toast.update(tid, {
render: "Не удалось войти в аккаунт",
type: "error",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
setIsSending(false);
return;
}
userStore.login(data.profile, data.profileToken.token);
if (remember) {
setJWT(data.profile.id, data.profileToken.token);
}
toast.update(tid, {
render: "Вход успешен!",
type: "success",
autoClose: 2500,
isLoading: false,
closeOnClick: true,
draggable: true,
});
} }
useEffect(() => { useEffect(() => {
@ -53,7 +97,7 @@ export function LoginPage() {
}, [userStore.user]); }, [userStore.user]);
return ( return (
<section className="bg-gray-50 dark:bg-gray-900"> <section>
<div className="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0"> <div className="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700"> <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
<div className="p-6 space-y-4 md:space-y-6 sm:p-8"> <div className="p-6 space-y-4 md:space-y-6 sm:p-8">

View file

@ -6,9 +6,11 @@ import { useRouter } from "next/navigation";
import { SettingsModal } from "#/components/SettingsModal/SettingsModal"; import { SettingsModal } from "#/components/SettingsModal/SettingsModal";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { usePreferencesStore } from "#/store/preferences";
export const MenuPage = () => { export const MenuPage = () => {
const userStore = useUserStore(); const userStore = useUserStore();
const preferenceStore = usePreferencesStore();
const router = useRouter(); const router = useRouter();
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
@ -22,7 +24,7 @@ export const MenuPage = () => {
return ( return (
<> <>
{userStore.user && ( {userStore.user && (
<div className="flex flex-col gap-2"> <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"> <div className="flex flex-wrap items-center gap-2">
<Link <Link
href={`/profile/${userStore.user.id}`} href={`/profile/${userStore.user.id}`}
@ -81,62 +83,42 @@ export const MenuPage = () => {
</button> </button>
</div> </div>
</div> </div>
<Link href="/favorites" className="flex-1 sm:hidden"> {preferenceStore.flags.showFifthButton != 3 ?
<Card> <Link href="/favorites" className="flex-1 sm:hidden">
<div className="flex items-center gap-2"> <Card>
<span <div className="flex items-center gap-2">
className={`iconify material-symbols--favorite-outline w-6 h-6`} <span
></span> className={`iconify material-symbols--favorite-outline w-6 h-6`}
<p>Избранное</p> ></span>
</div> <p>Избранное</p>
</Card>
</Link>
<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>
<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>
<Link href={"https://t.me/anix_web"} className="flex-1">
<Card>
<div className="flex items-center gap-2">
<span className="w-8 h-8 iconify fa6-brands--telegram"></span>
<div>
<p>Телеграм канал</p>
<p className="text-sm text-gray-400 dark:text-gray-200">
@anix_web
</p>
</div> </div>
</div> </Card>
</Card> </Link>
</Link> : ""}
<Link href={"https://wah.su/radiquum"} className="flex-1"> {preferenceStore.flags.showFifthButton != 4 ?
<Card> <Link href="/collections" className="flex-1 sm:hidden">
<div className="flex items-center gap-2"> <Card>
<span className="w-8 h-8 iconify mdi--code"></span> <div className="flex items-center gap-2">
<div> <span
<p>Разработчик</p> className={`iconify material-symbols--collections-bookmark-outline w-6 h-6`}
<p className="text-sm text-gray-400 dark:text-gray-200"> ></span>
Radiquum <p>Коллекции</p>
</p>
</div> </div>
</div> </Card>
</Card> </Link>
</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 <SettingsModal
isOpen={isSettingModalOpen} isOpen={isSettingModalOpen}
setIsOpen={setIsSettingModalOpen} setIsOpen={setIsSettingModalOpen}

View file

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { Spinner } from "../components/Spinner/Spinner"; import { Spinner } from "../components/Spinner/Spinner";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import useSWR from "swr"; import useSWR from "swr";
import { useSWRfetcher } from "#/api/utils";
import { ProfileUser } from "#/components/Profile/Profile.User"; import { ProfileUser } from "#/components/Profile/Profile.User";
import { ProfileBannedBanner } from "#/components/Profile/ProfileBannedBanner"; import { ProfileBannedBanner } from "#/components/Profile/ProfileBannedBanner";
@ -16,20 +17,6 @@ import { ProfileReleaseRatings } from "#/components/Profile/Profile.ReleaseRatin
import { ProfileReleaseHistory } from "#/components/Profile/Profile.ReleaseHistory"; import { ProfileReleaseHistory } from "#/components/Profile/Profile.ReleaseHistory";
import { ProfileEditModal } from "#/components/Profile/Profile.EditModal"; import { ProfileEditModal } from "#/components/Profile/Profile.EditModal";
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
export const ProfilePage = (props: any) => { export const ProfilePage = (props: any) => {
const authUser = useUserStore(); const authUser = useUserStore();
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
@ -41,7 +28,7 @@ export const ProfilePage = (props: any) => {
if (authUser.token) { if (authUser.token) {
url += `?token=${authUser.token}`; url += `?token=${authUser.token}`;
} }
const { data } = useSWR(url, fetcher); const { data, error } = useSWR(url, useSWRfetcher);
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@ -50,7 +37,7 @@ export const ProfilePage = (props: any) => {
} }
}, [data]); }, [data]);
if (!user) { if (!user && !error) {
return ( return (
<main className="flex items-center justify-center min-h-screen"> <main className="flex items-center justify-center min-h-screen">
<Spinner /> <Spinner />
@ -58,52 +45,19 @@ export const ProfilePage = (props: any) => {
); );
} }
const hasSocials = if (error) {
user.vk_page != "" || return (
user.tg_page != "" || <main className="flex items-center justify-center min-h-screen">
user.tt_page != "" || <div className="flex flex-col gap-2">
user.inst_page != "" || <h1 className="text-2xl font-bold">Ошибка</h1>
user.discord_page != "" || <p className="text-lg">
false; Произошла ошибка при загрузке профиля. Попробуйте обновить страницу
const socials = [ или зайдите позже.
{ </p>
name: "vk", </div>
nickname: user.vk_page, </main>
icon: "fa6-brands--vk", );
urlPrefix: "https://vk.com/", }
},
{
name: "telegram",
nickname: user.tg_page,
icon: "fa6-brands--telegram",
urlPrefix: "https://t.me/",
},
{
name: "discord",
nickname: user.discord_page,
icon: "fa6-brands--discord",
},
{
name: "tiktok",
nickname: user.tt_page,
icon: "fa6-brands--tiktok",
urlPrefix: "https://tiktok.com/@",
},
{
name: "instagram",
nickname: user.inst_page,
icon: "fa6-brands--instagram",
urlPrefix: "https://instagram.com/",
},
];
const hasChips =
user.is_verified ||
user.is_blocked ||
(user.roles && user.roles.length > 0) ||
isMyProfile;
const isPrivacy =
user.is_stats_hidden || user.is_counts_hidden || user.is_social_hidden;
return ( return (
<> <>
@ -115,57 +69,48 @@ export const ProfilePage = (props: any) => {
ban_expires={user.ban_expires} ban_expires={user.ban_expires}
/> />
<ProfilePrivacyBanner <ProfilePrivacyBanner
is_privacy={isPrivacy} is_privacy={
user.is_stats_hidden ||
user.is_counts_hidden ||
user.is_social_hidden
}
is_me_blocked={user.is_me_blocked} is_me_blocked={user.is_me_blocked}
/> />
</div> </div>
<div <div
className={`flex flex-wrap gap-2 ${ className={`grid grid-cols-1 gap-2 lg:grid-cols-2 ${
isPrivacy || user.is_banned || user.is_perm_banned ? "mt-4" : "" (
}`} user.is_banned ||
user.is_perm_banned ||
user.is_stats_hidden ||
user.is_counts_hidden ||
user.is_social_hidden
) ?
"mt-4"
: ""
} mb-4`}
> >
<div className="flex flex-col gap-2 w-full xl:w-[50%]"> <div className="flex flex-col gap-2">
<ProfileUser <ProfileUser
isOnline={user.is_online} avatar={user.avatar || ""}
avatar={user.avatar} login={user.login || ""}
login={user.login} status={user.status || ""}
status={user.status} roles={user.roles || []}
rating={user.rating_score || 0}
isMyProfile={isMyProfile || false}
isVerified={user.is_verified || false}
isOnline={user.is_online || false}
isSponsor={user.is_sponsor || false}
isBlocked={user.is_blocked || false}
socials={{ socials={{
isPrivate: user.is_social_hidden, vk: user.vk_page || null,
hasSocials: hasSocials, tg: user.tg_page || null,
socials: socials, tt: user.tt_page || null,
inst: user.inst_page || null,
discord: user.discord_page || null,
}} }}
chips={{ is_social_hidden={user.is_social_hidden}
hasChips: hasChips,
isMyProfile: isMyProfile,
isVerified: user.is_verified,
isSponsor: user.is_sponsor,
isBlocked: user.is_blocked,
roles: user.roles,
}}
rating={user.rating_score}
/> />
{!user.is_counts_hidden && (
<ProfileActivity
profile_id={user.id}
commentCount={user.comment_count}
videoCount={user.video_count}
collectionCount={user.collection_count}
friendsCount={user.friend_count}
/>
)}
{!user.is_stats_hidden && (
<div className="flex-col hidden gap-2 xl:flex">
{user.votes && user.votes.length > 0 && (
<ProfileReleaseRatings ratings={user.votes} token={authUser.token} profile_id={user.id} />
)}
{user.history && user.history.length > 0 && (
<ProfileReleaseHistory history={user.history} />
)}
</div>
)}
</div>
<div className="flex flex-col w-full gap-2 xl:flex-1 xl:w-auto ">
{authUser.token && ( {authUser.token && (
<ProfileActions <ProfileActions
isMyProfile={isMyProfile} isMyProfile={isMyProfile}
@ -180,6 +125,32 @@ export const ProfilePage = (props: any) => {
edit_setIsOpen={setIsOpen} edit_setIsOpen={setIsOpen}
/> />
)} )}
{!user.is_counts_hidden && (
<ProfileActivity
profile_id={user.id}
commentCount={user.comment_count}
commentPreview={user.release_comments_preview || []}
collectionCount={user.collection_count}
collectionPreview={user.collections_preview || []}
friendsCount={user.friend_count}
friendsPreview={user.friends_preview || []}
token={authUser.token}
isMyProfile={isMyProfile || false}
/>
)}
{!user.is_stats_hidden && (
<div className="flex-col hidden gap-2 lg:flex">
{user.votes && user.votes.length > 0 && (
<ProfileReleaseRatings
ratings={user.votes}
token={authUser.token}
profile_id={user.id}
/>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{!user.is_stats_hidden && ( {!user.is_stats_hidden && (
<> <>
<ProfileStats <ProfileStats
@ -195,19 +166,28 @@ export const ProfilePage = (props: any) => {
profile_id={user.id} profile_id={user.id}
/> />
<ProfileWatchDynamic watchDynamic={user.watch_dynamics || []} /> <ProfileWatchDynamic watchDynamic={user.watch_dynamics || []} />
<div className="flex flex-col gap-2 xl:hidden"> <div className="flex flex-col gap-2 lg:hidden">
{user.votes && user.votes.length > 0 && ( {user.votes && user.votes.length > 0 && (
<ProfileReleaseRatings ratings={user.votes} token={authUser.token} profile_id={user.id} /> <ProfileReleaseRatings
)} ratings={user.votes}
{user.history && user.history.length > 0 && ( token={authUser.token}
<ProfileReleaseHistory history={user.history} /> profile_id={user.id}
/>
)} )}
</div> </div>
{user.history && user.history.length > 0 && (
<ProfileReleaseHistory history={user.history} />
)}
</> </>
)} )}
</div> </div>
</div> </div>
<ProfileEditModal isOpen={isOpen && isMyProfile} setIsOpen={setIsOpen} token={authUser.token} profile_id={user.id}/> <ProfileEditModal
isOpen={isOpen && isMyProfile}
setIsOpen={setIsOpen}
token={authUser.token}
profile_id={user.id}
/>
</> </>
); );
}; };

View file

@ -5,23 +5,25 @@ import { useState, useEffect } from "react";
import { useScrollPosition } from "#/hooks/useScrollPosition"; import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "../store/auth"; import { useUserStore } from "../store/auth";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { ReleaseLink169Related } from "#/components/ReleaseLink/ReleaseLink.16_9Related"; import { useSWRfetcher } from "#/api/utils";
import { Card } from "flowbite-react";
import { Poster } from "#/components/ReleasePoster/Poster";
import { ReleaseChips } from "#/components/ReleasePoster/Chips";
import { PosterWithStuff } from "#/components/ReleasePoster/PosterWithStuff";
import Link from "next/link";
const fetcher = async (url: string) => { const profile_lists = {
const res = await fetch(url); // 0: "Не смотрю",
1: { name: "Смотрю", bg_color: "bg-green-500" },
if (!res.ok) { 2: { name: "В планах", bg_color: "bg-purple-500" },
const error = new Error(`An error occurred while fetching the data. status: ${res.status}`); 3: { name: "Просмотрено", bg_color: "bg-blue-500" },
error.message = await res.json(); 4: { name: "Отложено", bg_color: "bg-yellow-500" },
throw error; 5: { name: "Брошено", bg_color: "bg-red-500" },
}
return res.json();
}; };
const YearSeason = ["_", "Зима", "Весна", "Лето", "Осень"];
export function RelatedPage(props: {id: number|string, title: string}) { export function RelatedPage(props: { id: number | string; title: string }) {
const token = useUserStore((state) => state.token); const token = useUserStore((state) => state.token);
const [isLoadingEnd, setIsLoadingEnd] = useState(false);
const getKey = (pageIndex: number, previousPageData: any) => { const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.content.length) return null; if (previousPageData && !previousPageData.content.length) return null;
@ -33,7 +35,7 @@ export function RelatedPage(props: {id: number|string, title: string}) {
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 1 } { initialSize: 1 }
); );
@ -45,7 +47,6 @@ export function RelatedPage(props: {id: number|string, title: string}) {
allReleases.push(...data[i].content); allReleases.push(...data[i].content);
} }
setContent(allReleases); setContent(allReleases);
setIsLoadingEnd(true);
} }
}, [data]); }, [data]);
@ -64,22 +65,95 @@ export function RelatedPage(props: {id: number|string, title: string}) {
Франшиза {props.title} Франшиза {props.title}
</h1> </h1>
</div> </div>
{content && content.length > 0 ? ( {content && content.length > 0 ?
<div className="flex flex-col gap-4 my-4"> <div className="flex flex-col gap-4 my-4">
{content.map((release, index) => { {content.map((release, index) => {
return <ReleaseLink169Related {...release} key={release.id} _position={index + 1} /> const genres = [];
const grade =
release.grade ? Number(release.grade.toFixed(1)) : null;
const profile_list_status = release.profile_list_status || null;
let user_list = null;
if (profile_list_status != null || profile_list_status != 0) {
user_list = profile_lists[profile_list_status];
}
if (release.genres) {
const genres_array = release.genres.split(",");
genres_array.forEach((genre) => {
genres.push(genre.trim());
});
}
return (
<Link href={`/release/${release.id}`} key={release.id}>
<Card>
<div className="grid grid-cols-1 justify-center lg:grid-cols-[1fr_1fr_2fr] gap-4">
<div className="flex items-center justify-center">
<h1 className="inline-block text-6xl font-bold text-center text-transparent bg-gradient-to-r from-blue-600 via-purple-500 to-indigo-500 dark:from-blue-500 dark:via-purple-400 dark:to-indigo-300 bg-clip-text ">
{release.season ? YearSeason[release.season] : ""}
{release.season ?
<br />
: ""}
{release.year ? release.year : ""}
</h1>
</div>
<div className="flex items-center justify-center lg:hidden">
<div className="max-w-64">
<PosterWithStuff {...release} />
</div>
</div>
<div className="hidden lg:flex">
<Poster image={release.image} className="h-auto" />
</div>
<div className="flex-col hidden gap-2 lg:flex">
<ReleaseChips
{...release}
user_list={user_list}
grade={grade}
/>
<div>
{genres.length > 0 &&
genres.map((genre: string, index: number) => {
return (
<span
key={`release_${props.id}_genre_${genre}_${index}`}
className="font-light dark:text-white md:text-sm lg:text-base xl:text-lg"
>
{index > 0 && ", "}
{genre}
</span>
);
})}
</div>
{release.title_ru && (
<p className="text-xl font-bold dark:text-white md:text-2xl">
{release.title_ru}
</p>
)}
{release.title_original && (
<p className="text-sm text-gray-600 dark:text-gray-300 md:text-base">
{release.title_original}
</p>
)}
{release.description && (
<p className="mt-2 text-sm font-light dark:text-white lg:text-base xl:text-lg line-clamp-2">
{release.description}
</p>
)}
</div>
</div>
</Card>
</Link>
);
})} })}
</div> </div>
) : !isLoadingEnd || isLoading ? ( : isLoading ?
<div className="flex flex-col items-center justify-center min-w-full min-h-screen"> <div className="flex flex-col items-center justify-center min-w-full min-h-screen">
<Spinner /> <Spinner />
</div> </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> <span className="w-24 h-24 iconify-color twemoji--broken-heart"></span>
<p>В франшизе пока ничего нет...</p> <p>В франшизе пока ничего нет...</p>
</div> </div>
)} }
{data && {data &&
data[data.length - 1].current_page < data[data.length - 1].current_page <
data[data.length - 1].total_page_count && ( data[data.length - 1].total_page_count && (

View file

@ -2,8 +2,7 @@
import useSWR from "swr"; import useSWR from "swr";
import { Spinner } from "#/components/Spinner/Spinner"; import { Spinner } from "#/components/Spinner/Spinner";
const fetcher = (...args: any) => import { useSWRfetcher } from "#/api/utils";
fetch([...args] as any).then((res) => res.json());
import { useUserStore } from "#/store/auth"; import { useUserStore } from "#/store/auth";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -33,7 +32,7 @@ export const ReleasePage = (props: any) => {
if (userStore.token) { if (userStore.token) {
url += `?token=${userStore.token}`; url += `?token=${userStore.token}`;
} }
const { data, isLoading, error } = useSWR(url, fetcher); const { data, isLoading, error } = useSWR(url, useSWRfetcher);
return [data, isLoading, error]; return [data, isLoading, error];
} }
const [data, isLoading, error] = useFetch(props.id); const [data, isLoading, error] = useFetch(props.id);
@ -49,83 +48,106 @@ export const ReleasePage = (props: any) => {
} }
}, [data]); }, [data]);
return data ? ( if (isLoading) {
<> return (
<div className="flex flex-col lg:grid lg:grid-cols-[70%_30%] gap-2 grid-flow-row-dense"> <main className="flex items-center justify-center min-h-screen">
<div className="[grid-column:1] [grid-row:span_2]"> <Spinner />
<ReleaseInfoBasics </main>
image={data.release.image} );
title={{ }
ru: data.release.title_ru,
original: data.release.title_original, if (error) {
}} return (
description={data.release.description} <main className="flex items-center justify-center min-h-screen">
note={data.release.note} <div className="flex flex-col gap-2">
release_id={data.release.id} <h1 className="text-2xl font-bold">Ошибка</h1>
/> <p className="text-lg">
Произошла ошибка при загрузке релиза. Попробуйте обновить страницу
или зайдите позже.
</p>
</div> </div>
<div className="[grid-column:2]"> </main>
<ReleaseInfoInfo );
country={data.release.country} }
aired_on_date={data.release.aired_on_date}
year={data.release.year} return (
episodes={{ <div className="flex flex-col gap-2">
total: data.release.episodes_total, <div className="grid grid-cols-1 lg:grid-cols-[70%_30%] gap-2 grid-flow-row-dense">
released: data.release.episodes_released, <ReleaseInfoBasics
}} image={data.release.image}
season={data.release.season} title={{
status={data.release.status ? data.release.status.name : "Анонс"} ru: data.release.title_ru,
duration={data.release.duration} original: data.release.title_original,
category={data.release.category.name} }}
broadcast={data.release.broadcast} description={data.release.description}
studio={data.release.studio} note={data.release.note}
author={data.release.author} release_id={data.release.id}
director={data.release.director} />
genres={data.release.genres} <ReleaseInfoInfo
/> country={data.release.country}
</div> aired_on_date={data.release.aired_on_date}
<div className="[grid-column:2]"> year={data.release.year}
<ReleaseInfoUserList episodes={{
userList={userList} total: data.release.episodes_total,
isFavorite={userFavorite} released: data.release.episodes_released,
release_id={data.release.id} }}
season={data.release.season}
status={data.release.status ? data.release.status.name : "Анонс"}
duration={data.release.duration}
category={data.release.category.name}
broadcast={data.release.broadcast}
studio={data.release.studio}
author={data.release.author}
director={data.release.director}
genres={data.release.genres}
/>
<ReleaseInfoUserList
userList={userList}
isFavorite={userFavorite}
release_id={data.release.id}
token={userStore.token}
profile_id={userStore.user ? userStore.user.id : null}
setUserList={setUserList}
setIsFavorite={setUserFavorite}
collection_count={data.release.collection_count}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[70%_30%] gap-2 grid-flow-row-dense">
<div className="flex flex-col gap-2">
{data.release.status &&
data.release.status.name.toLowerCase() != "анонс" && (
<>
{preferenceStore.params.experimental.newPlayer ?
<ReleasePlayerCustom id={props.id} token={userStore.token} />
: <ReleasePlayer id={props.id} />}
</>
)}
<CommentsMain
release_id={props.id}
token={userStore.token} token={userStore.token}
profile_id={userStore.user ? userStore.user.id : null} comments={data.release.comments}
setUserList={setUserList}
setIsFavorite={setUserFavorite}
collection_count={data.release.collection_count}
/> />
</div> </div>
{data.release.status && <div className="flex flex-col gap-2">
data.release.status.name.toLowerCase() != "анонс" && ( {data.release.status &&
<div className="[grid-column:1] [grid-row:span_12]"> data.release.status.name.toLowerCase() != "анонс" && (
{preferenceStore.params.experimental.newPlayer ? ( <div className="[grid-column:2]">
<ReleasePlayerCustom id={props.id} token={userStore.token} /> <ReleaseInfoRating
) : ( release_id={props.id}
<ReleasePlayer id={props.id} /> grade={data.release.grade}
)} token={userStore.token}
</div> votes={{
)} 1: data.release.vote_1_count,
{data.release.status && 2: data.release.vote_2_count,
data.release.status.name.toLowerCase() != "анонс" && ( 3: data.release.vote_3_count,
<div className="[grid-column:2]"> 4: data.release.vote_4_count,
<ReleaseInfoRating 5: data.release.vote_5_count,
release_id={props.id} total: data.release.vote_count,
grade={data.release.grade} user: data.release.your_vote,
token={userStore.token} }}
votes={{ />
1: data.release.vote_1_count, </div>
2: data.release.vote_2_count, )}
3: data.release.vote_3_count,
4: data.release.vote_4_count,
5: data.release.vote_5_count,
total: data.release.vote_count,
user: data.release.your_vote,
}}
/>
</div>
)}
<div className="[grid-column:2] [grid-row:span_4]">
<InfoLists <InfoLists
completed={data.release.completed_count} completed={data.release.completed_count}
planned={data.release.plan_count} planned={data.release.plan_count}
@ -133,36 +155,18 @@ export const ReleasePage = (props: any) => {
delayed={data.release.hold_on_count} delayed={data.release.hold_on_count}
watching={data.release.watching_count} watching={data.release.watching_count}
/> />
</div> {data.release.screenshot_images.length > 0 && (
{data.release.screenshot_images.length > 0 && (
<div className="[grid-column:2] [grid-row:span_11]">
<ReleaseInfoScreenshots images={data.release.screenshot_images} /> <ReleaseInfoScreenshots images={data.release.screenshot_images} />
</div> )}
)} {data.release.related_releases.length > 0 && (
{data.release.related_releases.length > 0 && (
<div className="[grid-column:2] [grid-row:span_2]">
<ReleaseInfoRelated <ReleaseInfoRelated
release_id={props.id} release_id={props.id}
related={data.release.related} related={data.release.related}
related_releases={data.release.related_releases} related_releases={data.release.related_releases}
/> />
</div> )}
)}
<div className="[grid-column:1] [grid-row:span_32]">
<CommentsMain
release_id={props.id}
token={userStore.token}
comments={data.release.comments}
/>
</div> </div>
</div> </div>
</>
) : (
<div className="flex h-[100dvh] w-full justify-center items-center">
<Spinner />
</div> </div>
); );
}; };

View file

@ -8,23 +8,10 @@ import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useUserStore } from "../store/auth"; import { useUserStore } from "../store/auth";
import { Button, Dropdown, Modal } from "flowbite-react"; import { Button, Dropdown, DropdownItem, Modal, ModalBody, ModalFooter, ModalHeader } from "flowbite-react";
import { CollectionsSection } from "#/components/CollectionsSection/CollectionsSection"; import { CollectionsSection } from "#/components/CollectionsSection/CollectionsSection";
import { UserSection } from "#/components/UserSection/UserSection"; import { UserSection } from "#/components/UserSection/UserSection";
import { useSWRfetcher } from "#/api/utils";
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
const ListsMapping = { const ListsMapping = {
watching: { watching: {
@ -128,7 +115,7 @@ export function SearchPage() {
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2, revalidateFirstPage: false } { initialSize: 2, revalidateFirstPage: false }
); );
@ -174,7 +161,18 @@ export function SearchPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchVal]); }, [searchVal]);
if (error) return <div>failed to load: {error.message}</div>; if (error)
return (
<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>
);
return ( return (
<> <>
@ -237,39 +235,35 @@ export function SearchPage() {
</div> </div>
<div className="mt-2"> <div className="mt-2">
{data && data[0].related && <RelatedSection {...data[0].related} />} {data && data[0].related && <RelatedSection {...data[0].related} />}
{content ? ( {content ?
content.length > 0 ? ( content.length > 0 ?
<> <>
{where == "collections" ? ( {where == "collections" ?
<CollectionsSection <CollectionsSection
sectionTitle="Найденные Коллекции" sectionTitle="Найденные Коллекции"
content={content} content={content}
/> />
) : where == "profiles" ? ( : where == "profiles" ?
<UserSection <UserSection
sectionTitle="Найденные Пользователи" sectionTitle="Найденные Пользователи"
content={content} content={content}
/> />
) : ( : <ReleaseSection
<ReleaseSection
sectionTitle="Найденные релизы" sectionTitle="Найденные релизы"
content={content} content={content}
/> />
)} }
</> </>
) : ( : <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--crying-cat"></span> <span className="w-24 h-24 iconify-color twemoji--crying-cat"></span>
<p>Странно, аниме не найдено, попробуйте другой запрос...</p> <p>Странно, аниме не найдено, попробуйте другой запрос...</p>
</div> </div>
) : isLoading && (
) : (
isLoading && (
<div className="flex items-center justify-center min-w-full min-h-screen"> <div className="flex items-center justify-center min-w-full min-h-screen">
<Spinner /> <Spinner />
</div> </div>
) )
)} }
{!content && !isLoading && !query && ( {!content && !isLoading && !query && (
<div className="flex flex-col items-center justify-center min-w-full gap-2 mt-12 text-xl"> <div className="flex flex-col items-center justify-center min-w-full gap-2 mt-12 text-xl">
<span className="w-16 h-16 iconify mdi--arrow-up animate-bounce"></span> <span className="w-16 h-16 iconify mdi--arrow-up animate-bounce"></span>
@ -277,11 +271,13 @@ export function SearchPage() {
</div> </div>
)} )}
</div> </div>
{data && {(
data.length > 1 && data &&
(where == "releases" data.length > 1 &&
? data[data.length - 1].releases.length == 25 (where == "releases" ?
: data[data.length - 1].content.length == 25) ? ( data[data.length - 1].releases.length == 25
: data[data.length - 1].content.length == 25)
) ?
<Button <Button
className="w-full" className="w-full"
color={"light"} color={"light"}
@ -292,9 +288,7 @@ export function SearchPage() {
<span className="text-lg">Загрузить ещё</span> <span className="text-lg">Загрузить ещё</span>
</div> </div>
</Button> </Button>
) : ( : ""}
""
)}
<FiltersModal <FiltersModal
isOpen={filtersModalOpen} isOpen={filtersModalOpen}
setIsOpen={setFiltersModalOpen} setIsOpen={setFiltersModalOpen}
@ -366,8 +360,8 @@ const FiltersModal = (props: {
return ( return (
<Modal show={props.isOpen} onClose={() => _cancel()}> <Modal show={props.isOpen} onClose={() => _cancel()}>
<Modal.Header>Фильтры</Modal.Header> <ModalHeader>Фильтры</ModalHeader>
<Modal.Body> <ModalBody>
<div className="my-4"> <div className="my-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-bold dark:text-white">Искать в</p> <p className="font-bold dark:text-white">Искать в</p>
@ -382,64 +376,58 @@ const FiltersModal = (props: {
return <></>; return <></>;
} else { } else {
return ( return (
<Dropdown.Item <DropdownItem
onClick={() => setWhere(item)} onClick={() => setWhere(item)}
key={`where--${item}`} key={`where--${item}`}
> >
{WhereMapping[item]} {WhereMapping[item]}
</Dropdown.Item> </DropdownItem>
); );
} }
})} })}
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
{props.isAuth && {props.isAuth && where == "list" && ListsMapping.hasOwnProperty(list) ?
where == "list" &&
ListsMapping.hasOwnProperty(list) ? (
<div className="my-4"> <div className="my-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-bold dark:text-white">Список</p> <p className="font-bold dark:text-white">Список</p>
<Dropdown label={ListsMapping[list].name} color="blue"> <Dropdown label={ListsMapping[list].name} color="blue">
{Object.keys(ListsMapping).map((item) => { {Object.keys(ListsMapping).map((item) => {
return ( return (
<Dropdown.Item <DropdownItem
onClick={() => setList(item)} onClick={() => setList(item)}
key={`list--${item}`} key={`list--${item}`}
> >
{ListsMapping[item].name} {ListsMapping[item].name}
</Dropdown.Item> </DropdownItem>
); );
})} })}
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
) : ( : ""}
"" {!["profiles", "collections"].includes(where) ?
)}
{!["profiles", "collections"].includes(where) ? (
<div className="my-4"> <div className="my-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-bold dark:text-white">Искать по</p> <p className="font-bold dark:text-white">Искать по</p>
<Dropdown label={TagMapping[searchBy].name} color="blue"> <Dropdown label={TagMapping[searchBy].name} color="blue">
{Object.keys(TagMapping).map((item) => { {Object.keys(TagMapping).map((item) => {
return ( return (
<Dropdown.Item <DropdownItem
onClick={() => setSearchBy(item)} onClick={() => setSearchBy(item)}
key={`tag--${item}`} key={`tag--${item}`}
> >
{TagMapping[item].name} {TagMapping[item].name}
</Dropdown.Item> </DropdownItem>
); );
})} })}
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
) : ( : ""}
"" </ModalBody>
)} <ModalFooter>
</Modal.Body>
<Modal.Footer>
<div className="flex justify-end w-full gap-2"> <div className="flex justify-end w-full gap-2">
<Button color="red" onClick={() => _cancel()}> <Button color="red" onClick={() => _cancel()}>
Отменить Отменить
@ -448,7 +436,7 @@ const FiltersModal = (props: {
Применить Применить
</Button> </Button>
</div> </div>
</Modal.Footer> </ModalFooter>
</Modal> </Modal>
); );
}; };

View file

@ -6,7 +6,6 @@ import { useState, useEffect } from "react";
import { useScrollPosition } from "#/hooks/useScrollPosition"; import { useScrollPosition } from "#/hooks/useScrollPosition";
import { useUserStore } from "../store/auth"; import { useUserStore } from "../store/auth";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
import { useRouter } from "next/navigation";
import { ReleaseSection } from "#/components/ReleaseSection/ReleaseSection"; import { ReleaseSection } from "#/components/ReleaseSection/ReleaseSection";
import { CollectionInfoBasics } from "#/components/CollectionInfo/CollectionInfo.Basics"; import { CollectionInfoBasics } from "#/components/CollectionInfo/CollectionInfo.Basics";
@ -14,24 +13,10 @@ import { InfoLists } from "#/components/InfoLists/InfoLists";
import { CollectionInfoControls } from "#/components/CollectionInfo/CollectionInfoControls"; import { CollectionInfoControls } from "#/components/CollectionInfo/CollectionInfoControls";
import { CommentsMain } from "#/components/Comments/Comments.Main"; import { CommentsMain } from "#/components/Comments/Comments.Main";
const fetcher = async (url: string) => { import { useSWRfetcher } from "#/api/utils";
const res = await fetch(url);
if (!res.ok) {
const error = new Error(
`An error occurred while fetching the data. status: ${res.status}`
);
error.message = await res.json();
throw error;
}
return res.json();
};
export const ViewCollectionPage = (props: { id: number }) => { export const ViewCollectionPage = (props: { id: number }) => {
const userStore = useUserStore(); const userStore = useUserStore();
const [isLoadingEnd, setIsLoadingEnd] = useState(false);
const router = useRouter();
function useFetchCollectionInfo(type: "info" | "comments") { function useFetchCollectionInfo(type: "info" | "comments") {
let url: string; let url: string;
@ -46,8 +31,8 @@ export const ViewCollectionPage = (props: { id: number }) => {
url += `${type != "info" ? "&" : "?"}token=${userStore.token}`; url += `${type != "info" ? "&" : "?"}token=${userStore.token}`;
} }
const { data, isLoading } = useSWR(url, fetcher); const { data, error, isLoading } = useSWR(url, useSWRfetcher);
return [data, isLoading]; return [data, error, isLoading];
} }
const getKey = (pageIndex: number, previousPageData: any) => { const getKey = (pageIndex: number, previousPageData: any) => {
if (previousPageData && !previousPageData.content.length) return null; if (previousPageData && !previousPageData.content.length) return null;
@ -58,14 +43,17 @@ export const ViewCollectionPage = (props: { id: number }) => {
return url; return url;
}; };
const [collectionInfo, collectionInfoIsLoading] = const [collectionInfo, collectionInfoError, collectionInfoIsLoading] =
useFetchCollectionInfo("info"); useFetchCollectionInfo("info");
const [collectionComments, collectionCommentsIsLoading] = const [
useFetchCollectionInfo("comments"); collectionComments,
collectionCommentsError,
collectionCommentsIsLoading,
] = useFetchCollectionInfo("comments");
const { data, error, isLoading, size, setSize } = useSWRInfinite( const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey, getKey,
fetcher, useSWRfetcher,
{ initialSize: 2 } { initialSize: 2 }
); );
@ -77,7 +65,6 @@ export const ViewCollectionPage = (props: { id: number }) => {
allReleases.push(...data[i].content); allReleases.push(...data[i].content);
} }
setContent(allReleases); setContent(allReleases);
setIsLoadingEnd(true);
} }
}, [data]); }, [data]);
@ -89,14 +76,35 @@ export const ViewCollectionPage = (props: { id: number }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollPosition]); }, [scrollPosition]);
if (isLoading) {
return (
<div className="flex items-center justify-center w-full h-screen">
<Spinner />
</div>
);
}
if (error || collectionInfoError) {
return (
<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>
);
}
return ( return (
<> <>
{collectionInfoIsLoading ? ( {collectionInfoIsLoading ?
<div className="flex items-center justify-center w-full h-screen"> <div className="flex items-center justify-center w-full h-screen">
<Spinner /> <Spinner />
</div> </div>
) : ( : collectionInfo && (
collectionInfo && (
<> <>
<div className="flex flex-col flex-wrap gap-2 px-2 pb-2 sm:flex-row"> <div className="flex flex-col flex-wrap gap-2 px-2 pb-2 sm:flex-row">
<CollectionInfoBasics <CollectionInfoBasics
@ -138,11 +146,7 @@ export const ViewCollectionPage = (props: { id: number }) => {
)} )}
</div> </div>
</div> </div>
{isLoading || !content || !isLoadingEnd ? ( {content && (
<div className="flex items-center justify-center w-full h-screen">
<Spinner />
</div>
) : (
<ReleaseSection <ReleaseSection
sectionTitle={"Релизов в коллекции: " + data[0].total_count} sectionTitle={"Релизов в коллекции: " + data[0].total_count}
content={content} content={content}
@ -150,7 +154,7 @@ export const ViewCollectionPage = (props: { id: number }) => {
)} )}
</> </>
) )
)} }
</> </>
); );
}; };

View file

@ -16,19 +16,26 @@ export async function generateMetadata(
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata> { ): Promise<Metadata> {
const id: string = params.id; const id: string = params.id;
const profile: any = await fetchDataViaGet( const { data, error } = await fetchDataViaGet(
`https://api.anixart.tv/profile/${id}` `https://api.anixart.tv/profile/${id}`
); );
const previousOG = (await parent).openGraph; const previousOG = (await parent).openGraph;
if (error) {
return {
title: "Ошибка",
description: "Ошибка",
};
};
return { return {
title: SectionTitleMapping[params.slug] + " - " + profile.profile.login, title:"Закладки Пользователя - " + data.profile.login + " - " + SectionTitleMapping[params.slug],
description: profile.profile.status, description: "Закладки Пользователя - " + data.profile.login + " - " + SectionTitleMapping[params.slug],
openGraph: { openGraph: {
...previousOG, ...previousOG,
images: [ images: [
{ {
url: profile.profile.avatar, // Must be an absolute URL url: data.profile.avatar, // Must be an absolute URL
width: 600, width: 600,
height: 600, height: 600,
}, },

View file

@ -1,26 +1,33 @@
import { BookmarksPage } from "#/pages/Bookmarks"; import { BookmarksPage } from "#/pages/Bookmarks";
import { fetchDataViaGet } from "#/api/utils"; import { fetchDataViaGet } from "#/api/utils";
import type { Metadata, ResolvingMetadata } from "next"; import type { Metadata, ResolvingMetadata } from "next";
export const dynamic = 'force-static'; export const dynamic = "force-static";
export async function generateMetadata( export async function generateMetadata(
{ params }, { params },
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata> { ): Promise<Metadata> {
const id: string = params.id; const id: string = params.id;
const profile: any = await fetchDataViaGet( const { data, error } = await fetchDataViaGet(
`https://api.anixart.tv/profile/${id}` `https://api.anixart.tv/profile/${id}`
); );
const previousOG = (await parent).openGraph; const previousOG = (await parent).openGraph;
if (error) {
return {
title: "Ошибка",
description: "Ошибка",
};
};
return { return {
title: "Закладки - " + profile.profile.login, title: "Закладки Пользователя - " + data.profile.login,
description: profile.profile.status, description: "Закладки Пользователя - " + data.profile.login,
openGraph: { openGraph: {
...previousOG, ...previousOG,
images: [ images: [
{ {
url: profile.profile.avatar, // Must be an absolute URL url: data.profile.avatar, // Must be an absolute URL
width: 600, width: 600,
height: 600, height: 600,
}, },
@ -30,5 +37,5 @@ export async function generateMetadata(
} }
export default function Index({ params }) { export default function Index({ params }) {
return <BookmarksPage profile_id={params.id}/>; return <BookmarksPage profile_id={params.id} />;
} }

View file

@ -1,43 +1,65 @@
import { CollectionsFullPage } from "#/pages/CollectionsFull"; import { CollectionsFullPage } from "#/pages/CollectionsFull";
import { fetchDataViaGet } from "#/api/utils"; import { fetchDataViaGet } from "#/api/utils";
import type { Metadata, ResolvingMetadata } from "next"; import type { Metadata, ResolvingMetadata } from "next";
export const dynamic = 'force-static'; export const dynamic = "force-static";
export async function generateMetadata( export async function generateMetadata(
{ params }, { params },
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata> { ): Promise<Metadata> {
const id: string = params.id; const id: string = params.id;
const profile: any = await fetchDataViaGet( const { data, error } = await fetchDataViaGet(
`https://api.anixart.tv/profile/${id}` `https://api.anixart.tv/profile/${id}`
); );
const previousOG = (await parent).openGraph; const previousOG = (await parent).openGraph;
if (error) {
return {
title: "Ошибка",
description: "Ошибка",
};
};
return { return {
title: "Коллекции - " + profile.profile.login, title: "Коллекции Пользователя - " + data.profile.login,
description: profile.profile.status, description: "Коллекции Пользователя - " + data.profile.login,
openGraph: { openGraph: {
...previousOG, ...previousOG,
images: [ images: [
{ {
url: profile.profile.avatar, // Must be an absolute URL url: data.profile.avatar, // Must be an absolute URL
width: 600, width: 600,
height: 600, height: 600,
}, },
], ],
}, },
}; };
} };
export default async function Collections({ params }) { export default async function Collections({ params }) {
const profile: any = await fetchDataViaGet( const { data, error } = await fetchDataViaGet(
`https://api.anixart.tv/profile/${params.id}` `https://api.anixart.tv/profile/${params.id}`
); );
if (error) {
return (
<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>
);
}
return ( return (
<CollectionsFullPage <CollectionsFullPage
type="profile" type="profile"
title={`Коллекции пользователя ${profile.profile.login}`} title={`Коллекции пользователя: ${data.profile.login}`}
profile_id={params.id} profile_id={params.id}
/> />
); );
} };

View file

@ -1,26 +1,33 @@
import { ProfilePage } from "#/pages/Profile"; import { ProfilePage } from "#/pages/Profile";
import { fetchDataViaGet } from "#/api/utils"; import { fetchDataViaGet } from "#/api/utils";
import type { Metadata, ResolvingMetadata } from "next"; import type { Metadata, ResolvingMetadata } from "next";
export const dynamic = 'force-static'; export const dynamic = "force-static";
export async function generateMetadata( export async function generateMetadata(
{ params }, { params },
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata> { ): Promise<Metadata> {
const id: string = params.id; const id: string = params.id;
const profile: any = await fetchDataViaGet( const { data, error } = await fetchDataViaGet(
`https://api.anixart.tv/profile/${id}` `https://api.anixart.tv/profile/${id}`
); );
const previousOG = (await parent).openGraph; const previousOG = (await parent).openGraph;
if (error) {
return {
title: "Ошибка",
description: "Ошибка",
};
}
return { return {
title: "Профиль - " + profile.profile.login, title: "Профиль - " + data.profile.login,
description: profile.profile.status, description: data.profile.status,
openGraph: { openGraph: {
...previousOG, ...previousOG,
images: [ images: [
{ {
url: profile.profile.avatar, // Must be an absolute URL url: data.profile.avatar, // Must be an absolute URL
width: 600, width: 600,
height: 600, height: 600,
}, },

View file

@ -3,12 +3,31 @@ import { fetchDataViaGet } from "#/api/utils";
import type { Metadata, ResolvingMetadata } from "next"; import type { Metadata, ResolvingMetadata } from "next";
export const dynamic = 'force-static'; export const dynamic = 'force-static';
const _getData = async (url: string) => {
const { data, error } = await fetchDataViaGet(url);
return [data, error];
}
export async function generateMetadata({ params }, parent: ResolvingMetadata): Promise<Metadata> { export async function generateMetadata({ params }, parent: ResolvingMetadata): Promise<Metadata> {
const id:string = params.id; const id:string = params.id;
const related: any = await fetchDataViaGet(`https://api.anixart.tv/related/${id}/0`);
const firstRelease: any = await fetchDataViaGet(`https://api.anixart.tv/release/${related.content[0].id}`);
const previousOG = (await parent).openGraph; const previousOG = (await parent).openGraph;
const [ related, relatedError ] = await _getData(`https://api.anixart.tv/related/${id}/0`);
if (relatedError || related.content.length == 0) {
return {
title: "Ошибка",
description: "Ошибка",
};
};
const [ firstRelease, firstReleaseError ] = await _getData(`https://api.anixart.tv/release/${related.content[0].id}`);
if (firstReleaseError) {
return {
title: "Ошибка",
description: "Ошибка",
};
};
return { return {
title: "Франшиза - " + firstRelease.release.related.name_ru || firstRelease.release.related.name, title: "Франшиза - " + firstRelease.release.related.name_ru || firstRelease.release.related.name,
description: firstRelease.release.description, description: firstRelease.release.description,
@ -27,7 +46,25 @@ export async function generateMetadata({ params }, parent: ResolvingMetadata): P
export default async function Related({ params }) { export default async function Related({ params }) {
const id: string = params.id; const id: string = params.id;
const related: any = await fetchDataViaGet(`https://api.anixart.tv/related/${id}/0`); const [ related, relatedError ] = await _getData(`https://api.anixart.tv/related/${id}/0`);
const firstRelease: any = await fetchDataViaGet(`https://api.anixart.tv/release/${related.content[0].id}`); if (relatedError || related.content.length == 0) {
return <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>
};
const [ firstRelease, firstReleaseError ] = await _getData(`https://api.anixart.tv/release/${related.content[0].id}`);
if (firstReleaseError) {
return <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>
};
return <RelatedPage id={id} title={firstRelease.release.related.name_ru || firstRelease.release.related.name} />; return <RelatedPage id={id} title={firstRelease.release.related.name_ru || firstRelease.release.related.name} />;
} }

View file

@ -1,24 +1,33 @@
import { CollectionsFullPage } from "#/pages/CollectionsFull"; import { CollectionsFullPage } from "#/pages/CollectionsFull";
import { fetchDataViaGet } from "#/api/utils"; import { fetchDataViaGet } from "#/api/utils";
import type { Metadata, ResolvingMetadata } from "next"; import type { Metadata, ResolvingMetadata } from "next";
export const dynamic = 'force-static'; export const dynamic = "force-static";
export async function generateMetadata( export async function generateMetadata(
{ params }, { params },
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata> { ): Promise<Metadata> {
const id = params.id; const id = params.id;
const release = await fetchDataViaGet(`https://api.anixart.tv/release/${id}`); const { data, error } = await fetchDataViaGet(
`https://api.anixart.tv/release/${id}`
);
const previousOG = (await parent).openGraph; const previousOG = (await parent).openGraph;
if (error) {
return {
title: "Ошибка",
description: "Ошибка",
};
}
return { return {
title: release.release.title_ru + " - в коллекциях", title: data.release.title_ru + " - в коллекциях",
description: release.release.description, description: data.release.description,
openGraph: { openGraph: {
...previousOG, ...previousOG,
images: [ images: [
{ {
url: release.release.image, // Must be an absolute URL url: data.release.image, // Must be an absolute URL
width: 600, width: 600,
height: 800, height: 800,
}, },
@ -28,13 +37,26 @@ export async function generateMetadata(
} }
export default async function Collections({ params }) { export default async function Collections({ params }) {
const release: any = await fetchDataViaGet( const { data, error } = await fetchDataViaGet(
`https://api.anixart.tv/release/${params.id}` `https://api.anixart.tv/release/${params.id}`
); );
if (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>;
};
return ( return (
<CollectionsFullPage <CollectionsFullPage
type="release" type="release"
title={release.release.title_ru + " в коллекциях"} title={data.release.title_ru + " в коллекциях"}
release_id={params.id} release_id={params.id}
/> />
); );

View file

@ -1,24 +1,33 @@
import { ReleasePage } from "#/pages/Release"; import { ReleasePage } from "#/pages/Release";
import { fetchDataViaGet } from "#/api/utils"; import { fetchDataViaGet } from "#/api/utils";
import type { Metadata, ResolvingMetadata } from "next"; import type { Metadata, ResolvingMetadata } from "next";
export const dynamic = 'force-static'; export const dynamic = "force-static";
export async function generateMetadata( export async function generateMetadata(
{ params }, { params },
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata> { ): Promise<Metadata> {
const id = params.id; const id = params.id;
const release = await fetchDataViaGet(`https://api.anixart.tv/release/${id}`); const { data, error } = await fetchDataViaGet(
`https://api.anixart.tv/release/${id}`
);
const previousOG = (await parent).openGraph; const previousOG = (await parent).openGraph;
if (error) {
return {
title: "Ошибка",
description: "Ошибка",
};
}
return { return {
title: release.release.title_ru, title: data.release.title_ru,
description: release.release.description, description: data.release.description,
openGraph: { openGraph: {
...previousOG, ...previousOG,
images: [ images: [
{ {
url: release.release.image, // Must be an absolute URL url: data.release.image, // Must be an absolute URL
width: 600, width: 600,
height: 800, height: 800,
}, },

View file

@ -44,14 +44,16 @@ export const useUserStore = create<userState>((set, get) => ({
const jwt = getJWT(); const jwt = getJWT();
if (jwt) { if (jwt) {
const _checkAuth = async () => { const _checkAuth = async () => {
const data = await fetchDataViaGet( const { data, error } = await fetchDataViaGet(
`${ENDPOINTS.user.profile}/${jwt.user_id}?token=${jwt.jwt}` `${ENDPOINTS.user.profile}/${jwt.user_id}?token=${jwt.jwt}`
); );
if (data && data.is_my_profile) {
get().login(data.profile, jwt.jwt); if (error || !data.is_my_profile) {
} else {
get().logout(); get().logout();
return;
} }
get().login(data.profile, jwt.jwt);
}; };
_checkAuth(); _checkAuth();
} else { } else {

View file

@ -11,6 +11,7 @@ interface preferencesState {
showChangelog?: boolean; showChangelog?: boolean;
enableAnalytics?: boolean; enableAnalytics?: boolean;
showNavbarTitles?: "always" | "links" | "selected" | "never"; showNavbarTitles?: "always" | "links" | "selected" | "never";
showFifthButton?: null | 3 | 4 | 5;
}; };
params: { params: {
isFirstLaunch?: boolean; isFirstLaunch?: boolean;
@ -22,7 +23,7 @@ interface preferencesState {
}; };
experimental?: { experimental?: {
newPlayer: boolean; newPlayer: boolean;
} };
// color: { // color: {
// primary: string; // primary: string;
// secondary: string; // secondary: string;
@ -44,6 +45,7 @@ export const usePreferencesStore = create<preferencesState>()(
showChangelog: true, showChangelog: true,
enableAnalytics: true, enableAnalytics: true,
showNavbarTitles: "always", showNavbarTitles: "always",
showFifthButton: null,
}, },
params: { params: {
isFirstLaunch: true, isFirstLaunch: true,
@ -54,8 +56,8 @@ export const usePreferencesStore = create<preferencesState>()(
bookmarksCategory: "watching", bookmarksCategory: "watching",
}, },
experimental: { experimental: {
newPlayer: false newPlayer: false,
} },
}, },
setHasHydrated: (state) => { setHasHydrated: (state) => {
set({ set({
@ -74,9 +76,12 @@ export const usePreferencesStore = create<preferencesState>()(
onRehydrateStorage: (state) => { onRehydrateStorage: (state) => {
return () => state.setHasHydrated(true); return () => state.setHasHydrated(true);
}, },
merge: (persistedState , currentState) => { merge: (persistedState, currentState) => {
return deepmerge(currentState as preferencesState, persistedState as preferencesState); return deepmerge(
} currentState as preferencesState,
persistedState as preferencesState
);
},
} }
) )
); );

View file

@ -6,10 +6,10 @@ AniX - это неофициальный веб-клиент для Android-пр
## Список изменений ## Список изменений
- [3.5.0](/public/changelog/3.5.0.md)
- [3.4.0](/public/changelog/3.4.0.md)
- [3.3.0](/public/changelog/3.3.0.md) - [3.3.0](/public/changelog/3.3.0.md)
- [3.2.3](/public/changelog/3.2.3.md) - [3.2.3](/public/changelog/3.2.3.md)
- [3.2.2](/public/changelog/3.2.2.md)
- [3.2.1](/public/changelog/3.2.1.md)
[другие версии](/public/changelog) [другие версии](/public/changelog)

View file

@ -18,10 +18,13 @@ export default async function middleware(
} }
let path = url.pathname.match(/\/api\/proxy\/(.*)/)?.[1] + url.search; let path = url.pathname.match(/\/api\/proxy\/(.*)/)?.[1] + url.search;
const data = await fetchDataViaGet(`${API_URL}/${path}`, isApiV2); const { data, error } = await fetchDataViaGet(
`${API_URL}/${path}`,
isApiV2
);
if (!data) { if (error) {
return new Response(JSON.stringify({ message: "Error Fetching Data" }), { return new Response(JSON.stringify(error), {
status: 500, status: 500,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -46,26 +49,33 @@ export default async function middleware(
const path = url.pathname.match(/\/api\/proxy\/(.*)/)?.[1] + url.search; const path = url.pathname.match(/\/api\/proxy\/(.*)/)?.[1] + url.search;
const ReqContentTypeHeader = request.headers.get("Content-Type") || ""; const ReqContentTypeHeader = request.headers.get("Content-Type") || "";
const ReqSignHeader = request.headers.get("Sign") || null;
let ResContentTypeHeader = ""; let ResContentTypeHeader = "";
let body = null; let body = null;
if (ReqContentTypeHeader.split(";")[0] == "multipart/form-data") { if (ReqContentTypeHeader.split(";")[0] == "multipart/form-data") {
ResContentTypeHeader = ReqContentTypeHeader; ResContentTypeHeader = ReqContentTypeHeader;
body = await request.arrayBuffer(); body = await request.arrayBuffer();
} else if (ReqContentTypeHeader == "application/x-www-form-urlencoded") {
ResContentTypeHeader = ReqContentTypeHeader;
} else { } else {
ResContentTypeHeader = "application/json; charset=UTF-8"; ResContentTypeHeader = "application/json; charset=UTF-8";
body = JSON.stringify(await request.json()); body = JSON.stringify(await request.json());
} }
const data = await fetchDataViaPost( let resHeaders = {};
resHeaders["Content-Type"] = ResContentTypeHeader;
ReqSignHeader && (resHeaders["Sign"] = ReqSignHeader);
const { data, error } = await fetchDataViaPost(
`${API_URL}/${path}`, `${API_URL}/${path}`,
body, body,
isApiV2, isApiV2,
ResContentTypeHeader resHeaders
); );
if (!data) { if (error) {
return new Response(JSON.stringify({ message: "Error Fetching Data" }), { return new Response(JSON.stringify(error), {
status: 500, status: 500,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View file

@ -1,8 +1,7 @@
const { withPlausibleProxy } = require("next-plausible"); const { withPlausibleProxy } = require("next-plausible");
const withFlowbiteReact = require("flowbite-react/plugin/nextjs");
module.exports = withPlausibleProxy({ /** @type {import('next').NextConfig} */
customDomain: "https://analytics.wah.su", const NextConfig = {
})({
reactStrictMode: false, reactStrictMode: false,
images: { images: {
unoptimized: true, unoptimized: true,
@ -10,68 +9,74 @@ module.exports = withPlausibleProxy({
async headers() { async headers() {
return [ return [
{ {
source: '/bookmarks/:slug*', source: "/bookmarks/:slug*",
headers: [ headers: [
{ {
key: 'Cache-Control', key: "Cache-Control",
value: 's-maxage=2592000, stale-while-revalidate=86400', value: "s-maxage=2592000, stale-while-revalidate=86400",
}, },
], ],
}, },
{ {
source: '/collection/:slug*', source: "/collection/:slug*",
headers: [ headers: [
{ {
key: 'Cache-Control', key: "Cache-Control",
value: 's-maxage=2592000, stale-while-revalidate=86400', value: "s-maxage=2592000, stale-while-revalidate=86400",
}, },
], ],
}, },
{ {
source: '/home/:slug*', source: "/home/:slug*",
headers: [ headers: [
{ {
key: 'Cache-Control', key: "Cache-Control",
value: 's-maxage=2592000, stale-while-revalidate=86400', value: "s-maxage=2592000, stale-while-revalidate=86400",
}, },
], ],
}, },
{ {
source: '/profile/:slug*', source: "/profile/:slug*",
headers: [ headers: [
{ {
key: 'Cache-Control', key: "Cache-Control",
value: 's-maxage=2592000, stale-while-revalidate=86400', value: "s-maxage=2592000, stale-while-revalidate=86400",
}, },
], ],
}, },
{ {
source: '/release/:slug*', source: "/release/:slug*",
headers: [ headers: [
{ {
key: 'Cache-Control', key: "Cache-Control",
value: 's-maxage=2592000, stale-while-revalidate=86400', value: "s-maxage=2592000, stale-while-revalidate=86400",
}, },
], ],
}, },
{ {
source: '/related/:slug*', source: "/related/:slug*",
headers: [ headers: [
{ {
key: 'Cache-Control', key: "Cache-Control",
value: 's-maxage=2592000, stale-while-revalidate=86400', value: "s-maxage=2592000, stale-while-revalidate=86400",
}, },
], ],
}, },
{ {
source: '/search', source: "/search",
headers: [ headers: [
{ {
key: 'Cache-Control', key: "Cache-Control",
value: 's-maxage=2592000, stale-while-revalidate=86400', value: "s-maxage=2592000, stale-while-revalidate=86400",
}, },
], ],
}, },
]; ];
}, },
}); };
const config = withPlausibleProxy({
customDomain: "https://analytics.wah.su",
})(withFlowbiteReact(NextConfig));
module.exports = config;

1860
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,21 +6,24 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"postinstall": "flowbite-react patch"
}, },
"dependencies": { "dependencies": {
"apexcharts": "^3.52.0", "apexcharts": "^3.52.0",
"deepmerge-ts": "^7.1.0", "deepmerge-ts": "^7.1.0",
"flowbite": "^2.4.1", "flowbite": "^2.4.1",
"flowbite-react": "^0.10.1", "flowbite-react": "^0.11.7",
"hls-video-element": "^1.5.0", "hls-video-element": "^1.5.0",
"markdown-to-jsx": "^7.4.7", "markdown-to-jsx": "^7.4.7",
"media-chrome": "^4.8.0", "media-chrome": "^4.8.0",
"next": "^14.2.13", "next": "^14.2.26",
"next-plausible": "^3.12.1", "next-plausible": "^3.12.1",
"prettier": "^3.5.3",
"react": "^18", "react": "^18",
"react-cropper": "^2.3.3", "react-cropper": "^2.3.3",
"react-dom": "^18", "react-dom": "^18",
"react-toastify": "^11.0.5",
"swiper": "^11.1.4", "swiper": "^11.1.4",
"swr": "^2.2.5", "swr": "^2.2.5",
"videojs-video-element": "^1.4.1", "videojs-video-element": "^1.4.1",
@ -38,8 +41,6 @@
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.5", "eslint-config-next": "14.2.5",
"postcss": "^8", "postcss": "^8",
"prettier": "^3.5.3",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.1" "tailwindcss": "^3.4.1"
} }
} }

View file

@ -14,13 +14,13 @@
- Исправлено отображение времени года в информации о релизе - Исправлено отображение времени года в информации о релизе
- Исправлена ошибка когда плеер не может загрузиться - Исправлена ошибка когда плеер не может загрузиться
# 3.3.0 - Update 1 ## 3.3.0 - Update 1
- Добавлена Кнопка пропуска опенинга (90 секунд) - Добавлена Кнопка пропуска опенинга (90 секунд)
- Исправлено обрезание квадратных видео в собственном плеере - Исправлено обрезание квадратных видео в собственном плеере
- Изменён вид навигации и добавлена возможность изменять отображение текста на кнопках - Изменён вид навигации и добавлена возможность изменять отображение текста на кнопках
# 3.3.0 - Update 2 ## 3.3.0 - Update 2
- Исправлен сброс скорости воспроизведения при смене серии в собственном плеере - Исправлен сброс скорости воспроизведения при смене серии в собственном плеере
- Исправлен парсинг ссылок источника kodik для некоторых аниме - Исправлен парсинг ссылок источника kodik для некоторых аниме

17
public/changelog/3.4.0.md Normal file
View file

@ -0,0 +1,17 @@
# 3.4.0
## Добавлено
- Добавлены уведомления о действиях пользователя на различных страницах (например: добавление в избранное или друзья).
- Добавлен показ ошибок при загрузке своего плеера.
## Изменено
- Улучшено отображение ошибок
- Улучшено отображение страницы релиза на некоторых релизах
- Добавлено больше пространства между иконками в навигации на телефонах
## Исправлено
- Вид карточек в окне поиска релизов для добавления в коллекцию
- Сброс добавленных релизов при изменении или создании коллекции при поиска другого запроса в окне поиска релизов

17
public/changelog/3.5.0.md Normal file
View file

@ -0,0 +1,17 @@
# 3.5.0
## Добавлено
- Добавлена страница о приложении
- Добавлена возможность добавить пятую кнопку в меню навигации на мобильных устройствах
## Изменено
- Стиль карточек для релизов был изменён на вертикальный
- На мобильных устройствах постер на странице релиза теперь по середине
- Позиция лицензированных сервисов теперь под постером
- На мобильных устройствах позиция меню была смещена вниз
## Исправлено
- Ошибка своего плеера не сбрасывалась, если удалось получить ссылку при переключении серии

Some files were not shown because too many files have changed in this diff Show more