From f1f7feffaa6ab39a3c6579501cc4950645c6201f Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 22 Nov 2025 05:45:54 +0300 Subject: [PATCH] feat: /titles page with search and sort functionality. Website header added --- modules/frontend/package-lock.json | 240 ++++++++++++++++++ modules/frontend/package.json | 1 + modules/frontend/src/App.css | 42 --- modules/frontend/src/App.tsx | 3 + .../frontend/src/components/Header/Header.tsx | 90 +++++++ .../components/LayoutSwitch/LayoutSwitch.tsx | 28 ++ .../src/components/ListView/ListView.tsx | 96 ++----- .../src/components/SearchBar/SearchBar.tsx | 34 +++ .../TitlesSortBox/TitlesSortBox.tsx | 67 +++++ modules/frontend/src/index.css | 1 + .../src/pages/TitlesPage/TitlesPage.tsx | 176 ++++++++++--- modules/frontend/vite.config.ts | 2 +- 12 files changed, 625 insertions(+), 155 deletions(-) create mode 100644 modules/frontend/src/components/Header/Header.tsx create mode 100644 modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx create mode 100644 modules/frontend/src/components/SearchBar/SearchBar.tsx create mode 100644 modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index f5dde46..40bb520 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "nyanimedb-frontend", "version": "0.0.0", "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", @@ -30,6 +31,9 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" + }, + "engines": { + "node": "20.x" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -906,6 +910,79 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/@heroicons/react": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", @@ -1057,6 +1134,103 @@ "node": ">= 8" } }, + "node_modules/@react-aria/focus": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", + "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", + "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.38", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", @@ -1350,6 +1524,15 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", @@ -1607,6 +1790,33 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2221,6 +2431,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4086,6 +4305,12 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", @@ -4177,6 +4402,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4301,6 +4532,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index cc468cf..e0b65ba 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", diff --git a/modules/frontend/src/App.css b/modules/frontend/src/App.css index b9d355d..e69de29 100644 --- a/modules/frontend/src/App.css +++ b/modules/frontend/src/App.css @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index f67c37e..909ad6c 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,10 +2,13 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import { Header } from "./components/Header/Header"; const App: React.FC = () => { + const username = "nihonium"; return ( +
} /> } /> diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx new file mode 100644 index 0000000..98b1295 --- /dev/null +++ b/modules/frontend/src/components/Header/Header.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid"; + +type HeaderProps = { + username?: string; +}; + +export const Header: React.FC = ({ username }) => { + const [menuOpen, setMenuOpen] = useState(false); + + const toggleMenu = () => setMenuOpen(!menuOpen); + + return ( +
+
+
+ + {/* Левый блок — логотип / название */} +
+ + NyanimeDB + +
+ + {/* Центр — ссылки на разделы (desktop) */} + + + {/* Правый блок — профиль */} +
+ {username ? ( + + {username} + + ) : ( + + Login + + )} +
+ + {/* Бургер для мобильного */} +
+ +
+ +
+
+ + {/* Мобильное меню */} + {menuOpen && ( +
+ +
+ )} +
+ ); +}; diff --git a/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx new file mode 100644 index 0000000..679feea --- /dev/null +++ b/modules/frontend/src/components/LayoutSwitch/LayoutSwitch.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; + +export type LayoutSwitchProps = { + layout: "square" | "horizontal" + setLayout: (value: React.SetStateAction<"square" | "horizontal">) => void +}; + +export function LayoutSwitch({ + layout, + setLayout +}: LayoutSwitchProps) { + + return ( +
+ +
+ ); +} diff --git a/modules/frontend/src/components/ListView/ListView.tsx b/modules/frontend/src/components/ListView/ListView.tsx index e0e8ab9..67488c0 100644 --- a/modules/frontend/src/components/ListView/ListView.tsx +++ b/modules/frontend/src/components/ListView/ListView.tsx @@ -1,103 +1,49 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid"; -import type { CursorObj } from "../../api"; export type ListViewProps = { - fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>; + items: T[]; + layout: "square" | "horizontal"; renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode; - pageSize?: number; - searchPlaceholder?: string; - setSearch: any; + onLoadMore: () => void; + hasMore: boolean; + loadingMore: boolean; }; export function ListView({ - fetchItems, + items, + layout, renderItem, - pageSize = 20, - searchPlaceholder = "Search...", + onLoadMore, + hasMore, + loadingMore }: ListViewProps) { - const [items, setItems] = useState([]); - const [cursorObj, setCursorObj] = useState(undefined); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [search, setSearch] = useState(""); - const [layout, setLayout] = useState<"square" | "horizontal">("horizontal"); - const [error, setError] = useState(null); - - const loadItems = async (reset: boolean = false) => { - try { - if (reset) { - setLoading(true); - setCursorObj(undefined); - } else { - setLoadingMore(true); - } - - const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : "" - console.log("encoded cursor: " + cursorStr) - - const result = await fetchItems(cursorStr, pageSize); - - if (reset) setItems(result.items); - else setItems(prev => [...prev, ...result.items]); - - setCursorObj(result.cursor); - setError(null); - } catch (err: any) { - console.error(err); - setError("Failed to fetch items."); - } finally { - setLoading(false); - setLoadingMore(false); - } - }; - - useEffect(() => { - loadItems(true); - }, [search]); return ( -
-
- setSearch(e.target.value)} - className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black" - /> - -
- - {error &&
{error}
} - +
+ {/* Items */}
{items.map(item => renderItem(item, layout))}
- {cursorObj && ( -
+ {/* Load More */} + {hasMore && ( +
)} - - {loading &&
Loading...
}
); } diff --git a/modules/frontend/src/components/SearchBar/SearchBar.tsx b/modules/frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..87aee66 --- /dev/null +++ b/modules/frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,34 @@ +type SearchBarProps = { + placeholder?: string; + search: string; + setSearch: (value: string) => void; +}; + +export function SearchBar({ + placeholder = "Search...", + search, + setSearch, +}: SearchBarProps) { + return ( +
+ setSearch(e.target.value)} + className=" + w-full + px-4 + py-2 + border + border-gray-300 + rounded-lg + focus:outline-none + focus:ring-2 + focus:ring-blue-500 + text-black + " + /> +
+ ); +} diff --git a/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx new file mode 100644 index 0000000..f1f8195 --- /dev/null +++ b/modules/frontend/src/components/TitlesSortBox/TitlesSortBox.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import type { TitleSort } from "../../api"; +import { ChevronDownIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/24/solid"; + +type TitlesSortBoxProps = { + sort: TitleSort; + setSort: (value: TitleSort) => void; + sortForward: boolean; + setSortForward: (value: boolean) => void; +}; + +const SORT_OPTIONS: TitleSort[] = ["id", "rating", "year", "views"]; + +export function TitlesSortBox({ + sort, + setSort, + sortForward, + setSortForward, +}: TitlesSortBoxProps) { + const [open, setOpen] = useState(false); + + const toggleSortDirection = () => setSortForward(!sortForward); + const handleSortSelect = (newSort: TitleSort) => { + setSort(newSort); + setOpen(false); + }; + + return ( +
+ {/* Левая часть — смена направления */} + + + {/* Правая часть — выбор параметра */} + + + {/* Dropdown */} + {open && ( +
    + {SORT_OPTIONS.map(option => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/modules/frontend/src/index.css b/modules/frontend/src/index.css index e20de02..7b32a9b 100644 --- a/modules/frontend/src/index.css +++ b/modules/frontend/src/index.css @@ -5,4 +5,5 @@ html, body, #root { padding: 0; width: 100%; height: 100%; + @apply text-black bg-white; } \ No newline at end of file diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 4aeb5ec..0fec3c8 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -1,52 +1,154 @@ +import { useEffect, useState } from "react"; import { ListView } from "../../components/ListView/ListView"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import { DefaultService } from "../../api/services/DefaultService"; import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; -import type { Title } from "../../api"; -import { useState } from "react"; +import type { CursorObj, Title, TitleSort } from "../../api"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; + +const PAGE_SIZE = 10; -const PAGE_SIZE = 20; export default function TitlesPage() { + const [titles, setTitles] = useState([]); + const [nextPage, setNextPage] = useState([]); + const [cursor, setCursor] = useState(null); const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [sort, setSort] = useState("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); - const loadTitles = async (cursor: string, limit: number) => { - const result = await DefaultService.getTitles( - cursor, - undefined, - true, - search, - undefined, - undefined, - undefined, - undefined, - limit, - undefined, - 'all' - ); + const fetchPage = async (cursorObj: CursorObj | null) => { + const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; - return { - items: result.data ?? [], - cursor: result.cursor ?? null, - }; + try { + const result = await DefaultService.getTitles( + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, + undefined, + undefined, + undefined, + PAGE_SIZE, + undefined, + "all" + ); + + if ((result === undefined) || !result.data?.length) { + return { items: [], nextCursor: null }; + } + return { + items: result.data ?? [], + nextCursor: result.cursor ?? null + }; + } catch (err: any) { + if (err.status === 204) { + return { items: [], nextCursor: null }; + } + throw err; + } }; - return ( -
- -

Titles

+ // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoading(true); + setTitles([]); + setNextPage([]); + setCursor(null); - - pageSize={PAGE_SIZE} - fetchItems={loadTitles} - searchPlaceholder="Search titles..." - renderItem={(title, layout) => - layout === "square" - ? - : - } - setSearch={setSearch} - /> + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoading(false); + }; + + initLoad(); + }, [search, sort, sortForward]); + + +const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + // Подгружаем следующую страницу с сервера + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) { + setNextPage(next.items); + } + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + // Любой сценарий – выключаем loadingMore + setLoadingMore(false); +}; + + + + return ( +
+ +

Titles

+ +
+ + + +
+ + {loading &&
Loading...
} + + {!loading && titles.length === 0 && ( +
No titles found.
+ )} + + {titles.length > 0 && ( + <> + + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" + ? + : + } + /> + + {!cursor && nextPage.length == 0 && ( +
+ Результатов больше нет, было найдено {titles.length} тайтлов. +
+ )} + + )}
); } - diff --git a/modules/frontend/vite.config.ts b/modules/frontend/vite.config.ts index 6c261e6..554d630 100644 --- a/modules/frontend/vite.config.ts +++ b/modules/frontend/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ tailwindcss() ], server: { - host: '127.0.0.1', + host: '0.0.0.0', port: 8083, }, })