diff --git a/app/api/utils.js b/app/api/utils.js index 2d7ce12..c106e54 100644 --- a/app/api/utils.js +++ b/app/api/utils.js @@ -84,6 +84,11 @@ export function numberDeclension(number, one, two, five) { if ([5, 6, 7, 8, 9, 0].includes(last_num)) return five; } +export function unixToDate(unix) { + const date = new Date(unix * 1000); + return date.toLocaleString("ru-RU"); +} + export function sinceUnixDate(unixInSeconds) { const unix = Math.floor(unixInSeconds * 1000); const date = new Date(unix); @@ -108,3 +113,14 @@ export function sinceUnixDate(unixInSeconds) { return date.toLocaleString("ru-RU").split(",")[0]; } + +export function minutesToTime(min) { + const d = Math.floor(min / 1440); // 60*24 + const h = Math.floor((min - d * 1440) / 60); + const m = Math.round(min % 60); + + var dDisplay = d > 0 ? `${d} ${numberDeclension(d, "день", "дня", "дней")}, ` : ""; + var hDisplay = h > 0 ? `${h} ${numberDeclension(h, "час", "часа", "часов")}, ` : ""; + var mDisplay = m > 0 ? `${m} ${numberDeclension(m, "минута", "минуты", "минут")}` : ""; + return dDisplay + hDisplay + mDisplay; +} diff --git a/app/components/Chip/Chip.jsx b/app/components/Chip/Chip.jsx new file mode 100644 index 0000000..34ebb55 --- /dev/null +++ b/app/components/Chip/Chip.jsx @@ -0,0 +1,12 @@ +export const Chip = (props) => { + return ( + <div className={`rounded-sm ${props.bg_color || "bg-gray-500"}`}> + <p className="px-2 sm:px-4 py-0.5 sm:py-1 text-xs xl:text-base text-white"> + {props.name} + {props.name && props.devider ? props.devider : " "} + {props.name_2} + </p> + </div> + ); + }; + \ No newline at end of file diff --git a/app/components/Navbar/Navbar.jsx b/app/components/Navbar/Navbar.jsx index c479dc3..9049fa0 100644 --- a/app/components/Navbar/Navbar.jsx +++ b/app/components/Navbar/Navbar.jsx @@ -106,16 +106,13 @@ export const Navbar = () => { "ml-1 w-4 h-4 [transform:rotateX(180deg)] sm:transform-none", }} > - <Dropdown.Item - onClick={() => { - userStore.logout(); - }} - className="text-sm md:text-base" - > - <span - className={`iconify material-symbols--logout-rounded w-4 h-4 sm:w-6 sm:h-6`} - ></span> - <span>Выйти</span> + <Dropdown.Item className="text-sm md:text-base"> + <Link href="/profile" className="flex items-center gap-1"> + <span + className={`iconify ${pathname == `/profile/${userStore.user.id}` ? "font-bold mdi--user" : "mdi--user-outline"} w-4 h-4 sm:w-6 sm:h-6`} + ></span> + <span>Профиль</span> + </Link> </Dropdown.Item> {navLinks.map((link) => { return ( @@ -145,6 +142,17 @@ export const Navbar = () => { </Dropdown.Item> ); })} + <Dropdown.Item + onClick={() => { + userStore.logout(); + }} + className="text-sm md:text-base" + > + <span + className={`iconify material-symbols--logout-rounded w-4 h-4 sm:w-6 sm:h-6`} + ></span> + <span>Выйти</span> + </Dropdown.Item> </Dropdown> </div> ) : ( diff --git a/app/components/ReleaseLink/Chip.jsx b/app/components/ReleaseLink/Chip.jsx deleted file mode 100644 index 282026e..0000000 --- a/app/components/ReleaseLink/Chip.jsx +++ /dev/null @@ -1,11 +0,0 @@ -export const Chip = (props) => { - return ( - <div className={`rounded-sm ${props.bg_color || "bg-gray-500"}`}> - <p className="px-2 sm:px-4 py-0.5 sm:py-1 text-xs xl:text-base text-white"> - {props.name} - {props.name && props.devider ? props.devider : " "} - {props.name_2} - </p> - </div> - ); -}; diff --git a/app/components/ReleaseLink/ReleaseLink.jsx b/app/components/ReleaseLink/ReleaseLink.jsx index ff38f74..80fd24b 100644 --- a/app/components/ReleaseLink/ReleaseLink.jsx +++ b/app/components/ReleaseLink/ReleaseLink.jsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { sinceUnixDate } from "@/app/api/utils"; -import { Chip } from "./Chip"; +import { Chip } from "@/app/components/Chip/Chip"; export const ReleaseLink = (props) => { const grade = props.grade.toFixed(1); diff --git a/app/pages/Profile.jsx b/app/pages/Profile.jsx new file mode 100644 index 0000000..4406d84 --- /dev/null +++ b/app/pages/Profile.jsx @@ -0,0 +1,293 @@ +"use client"; +import { useUserStore } from "@/app/store/auth"; +import { useEffect, useState } from "react"; +import { fetchDataViaGet } from "../api/utils"; +import { Spinner } from "../components/Spinner/Spinner"; +import { Avatar, Card, Button, Table } from "flowbite-react"; +import { Chip } from "../components/Chip/Chip"; +import { unixToDate, minutesToTime } from "../api/utils"; +import { ReleaseLink } from "../components/ReleaseLink/ReleaseLink"; + +export const ProfilePage = (props) => { + const authUser = useUserStore((state) => state); + const [user, setUser] = useState(null); + const [isMyProfile, setIsMyProfile] = useState(false); + + useEffect(() => { + async function _getData() { + let url = `/api/profile/${props.id}`; + if (authUser.token) { + url += `?token=${authUser.token}`; + } + const data = await fetchDataViaGet(url); + setUser(data.profile); + setIsMyProfile(data.is_my_profile); + } + _getData(); + }, [authUser]); + + if (!user) { + return ( + <main className="flex items-center justify-center min-h-screen"> + <Spinner /> + </main> + ); + } + + const hasSocials = + user.vk_page != "" || + user.tg_page != "" || + user.tt_page != "" || + user.inst_page != "" || + user.discord_page != "" || + false; + const socials = [ + { + name: "vk", + nickname: user.vk_page, + 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", + }, + ]; + + return ( + <main className="container flex flex-col gap-4 px-4 pt-4 pb-32 mx-auto overflow-hidden xl:flex-row sm:pb-4"> + <div className="flex flex-col gap-4"> + <Card className="max-w-full"> + <div className="flex gap-2"> + {isMyProfile && <Chip bg_color="bg-blue-500" name="Мой профиль" />} + {user.is_banned && ( + <Chip bg_color="bg-red-500" name="Заблокирован" /> + )} + {user.is_verified && ( + <Chip bg_color="bg-green-500" name="Подтвержден" /> + )} + {/* {user.is_banned && <Chip bg_color="bg-red-500" name="Заблокирован" />} */} + + {/* <Chip bg="bg-blue-500" name={`Зарегистрирован: ${unixToDate(user.register_date)}`} /> */} + {/* <Chip bg="bg-blue-500" name={`Последний вход: ${unixToDate(user.last_activity_time)}`} /> */} + </div> + <Avatar + img={user.avatar} + rounded={true} + bordered={true} + size="lg" + className="justify-start" + > + <div className="space-y-1 font-medium dark:text-white"> + <div className="text-xl">{user.login}</div> + <div className="text-sm text-gray-500 dark:text-gray-400 max-w-64"> + {user.status} + </div> + </div> + </Avatar> + {hasSocials && ( + <Button.Group + outline={true} + className="overflow-x-scroll scrollbar-none" + > + {socials.map((social) => { + if (!social.nickname) return null; + if (social.name == "discord") return ( + <Button + color="light" + key={social.name} + as="a" + > + <div className="flex items-center justify-center gap-2"> + <span + className={`iconify-color h-4 w-4 sm:h-6 sm:w-6 ${social.icon}`} + ></span> + {social.nickname} + </div> + </Button> + ) + return ( + <Button + color="light" + key={social.name} + href={`${social.urlPrefix}/${social.nickname}`} + className="[&:is(a)]:hover:bg-gray-100" + > + <div className="flex items-center justify-center gap-2"> + <span + className={`iconify-color h-4 w-4 sm:h-6 sm:w-6 ${social.icon}`} + ></span> + {social.nickname} + </div> + </Button> + ); + })} + </Button.Group> + )} + </Card> + <div className="flex flex-wrap gap-4"> + <Card className="flex-1 max-w-full"> + <h1>Активность</h1> + <Table> + <Table.Body className="divide-y"> + <Table.Row> + <Table.Cell className="px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + Регистрация + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {unixToDate(user.register_date)} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + Был(а) в сети + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {unixToDate(user.last_activity_time)} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + Комментарий + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.comment_count} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + друзей + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.friend_count} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + видео + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.video_count} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + коллекций + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.collection_count} + </Table.Cell> + </Table.Row> + </Table.Body> + </Table> + </Card> + <Card className="flex-1 max-w-full"> + <h1>Статистика</h1> + <Table> + <Table.Body className="divide-y"> + <Table.Row> + <Table.Cell className="flex items-center px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + <span className="w-4 h-4 mr-2 iconify mdi--123 "></span> + Просмотрено серий + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.watched_episode_count} + </Table.Cell> + </Table.Row> + <Table.Row className="hidden sm:table-row"> + <Table.Cell className="flex items-center px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + <span className="w-4 h-4 mr-2 iconify mdi--clock "></span> + Время просмотра + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-pre sm:whitespace-nowrap dark:text-white"> + {minutesToTime(user.watched_time)} + </Table.Cell> + </Table.Row> + <Table.Row className="table-row sm:hidden"> + <Table.Cell className="flex items-center px-0 font-medium text-gray-900 whitespace-pre sm:whitespace-nowrap dark:text-white"> + <span className="w-4 h-4 mr-2 iconify mdi--clock "></span> + {minutesToTime(user.watched_time)} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="flex items-center px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + <span className="w-4 h-4 mr-2 iconify mdi--play "></span> + Смотрю + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.watching_count} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="flex items-center px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + <span className="w-4 h-4 mr-2 iconify mdi--note-multiple "></span> + В Планах + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.plan_count} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="flex items-center px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + <span className="w-4 h-4 mr-2 iconify mdi--tick "></span> + Просмотрено + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.completed_count} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="flex items-center px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + <span className="w-4 h-4 mr-2 iconify mdi--question-mark "></span> + Отложено + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.hold_on_count} + </Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell className="flex items-center px-0 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + <span className="w-4 h-4 mr-2 iconify mdi--erase "></span> + Брошено + </Table.Cell> + <Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {user.dropped_count} + </Table.Cell> + </Table.Row> + </Table.Body> + </Table> + </Card> + </div> + </div> + <div className="flex-1"> + <Card className="w-full max-w-full min-w-full"> + <h1>Недавно просмотренные</h1> + <div className="grid justify-center sm:grid-cols-[repeat(auto-fit,minmax(300px,1fr))] grid-cols-[100%] gap-2 min-w-full"> + {user.history.map((release) => { + return <ReleaseLink key={release.id} {...release} />; + })} + </div> + </Card> + </div> + </main> + ); +}; diff --git a/app/profile/[id]/page.js b/app/profile/[id]/page.js new file mode 100644 index 0000000..5117077 --- /dev/null +++ b/app/profile/[id]/page.js @@ -0,0 +1,17 @@ +import { ProfilePage } from "@/app/pages/Profile"; +import { fetchDataViaGet } from "@/app/api/utils"; +import { ENDPOINTS } from "@/app/api/config"; + +export async function generateMetadata({ params }) { + const id = params.id + const profile = await fetchDataViaGet(`${ENDPOINTS.user.profile}/${id}`); + + return { + title: "Профиль " + profile.profile.login, + }; +} + +export default async function Search({ params }) { + const id = params.id + return <ProfilePage id={id} />; +} diff --git a/app/profile/page.js b/app/profile/page.js new file mode 100644 index 0000000..4bfa736 --- /dev/null +++ b/app/profile/page.js @@ -0,0 +1,14 @@ +"use client" +import { useRouter } from "next/navigation"; +import { getJWT } from "../api/utils"; + +export default function myProfile() { + const user = getJWT() + const router = useRouter() + + if (!user) { + return router.push("/login") + } else { + return router.push(`/profile/${user.user_id}`) + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6c1c26e..07a7fef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "zustand": "^4.5.4" }, "devDependencies": { + "@iconify-json/fa6-brands": "^1.1.21", "@iconify-json/material-symbols": "^1.1.83", "@iconify-json/mdi": "^1.1.67", "@iconify-json/twemoji": "^1.1.15", @@ -190,6 +191,15 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@iconify-json/fa6-brands": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@iconify-json/fa6-brands/-/fa6-brands-1.1.21.tgz", + "integrity": "sha512-NS/BszVo8fUVpzA7/5b9tmkHzisZSUlm8kjdznk1Bux5p5QH3BxHZXrZUM5QsT90/7+omQC0EKukwf7H7nujZg==", + "dev": true, + "dependencies": { + "@iconify/types": "*" + } + }, "node_modules/@iconify-json/material-symbols": { "version": "1.1.83", "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.1.83.tgz", diff --git a/package.json b/package.json index d4378e3..fa0caa7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "zustand": "^4.5.4" }, "devDependencies": { + "@iconify-json/fa6-brands": "^1.1.21", "@iconify-json/material-symbols": "^1.1.83", "@iconify-json/mdi": "^1.1.67", "@iconify-json/twemoji": "^1.1.15", diff --git a/tailwind.config.js b/tailwind.config.js index 7a860b4..d8dda9d 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -10,7 +10,7 @@ module.exports = { flowbite.content(), ], plugins: [ - addIconSelectors(["mdi", "material-symbols", "twemoji"]), + addIconSelectors(["mdi", "material-symbols", "twemoji", "fa6-brands"]), require('tailwind-scrollbar'), flowbite.plugin(), ],