diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 5a25313..3ecfa2d 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,23 +1,34 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import UserPage from "./pages/UserPage/UserPage"; +import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { - const username = "nihonium"; + // Получаем username из localStorage + const username = localStorage.getItem("username") || undefined; + const userId = localStorage.getItem("userId"); + return (
- } /> {/* <-- маршрут для логина */} - } /> {/* <-- можно использовать тот же компонент для регистрации */} - } /> + } /> + } /> + + {/* /profile рендерит UsersIdPage с id из localStorage */} + : } + /> + + } /> } /> ); }; + export default App; \ No newline at end of file diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 6ce873e..185e5c3 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/api/v1', + BASE: '/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 52321b8..bb42012 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -20,7 +20,7 @@ export class DefaultService { * @param sort * @param sortForward * @param word - * @param status + * @param status List of title statuses to filter * @param rating * @param releaseYear * @param releaseSeason @@ -35,7 +35,7 @@ export class DefaultService { sort?: TitleSort, sortForward: boolean = true, word?: string, - status?: TitleStatus, + status?: Array, rating?: number, releaseYear?: number, releaseSeason?: ReleaseSeason, @@ -125,45 +125,112 @@ export class DefaultService { }, }); } + /** + * Partially update a user account + * Update selected user profile fields (excluding password). + * Password updates must be done via the dedicated auth-service (`/auth/`). + * Fields not provided in the request body remain unchanged. + * + * @param userId User ID (primary key) + * @param requestBody + * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). + * @throws ApiError + */ + public static updateUser( + userId: number, + requestBody: { + /** + * ID of the user avatar (references `images.id`); set to `null` to remove avatar + */ + avatar_id?: number | null; + /** + * User email (must be unique and valid) + */ + mail?: string; + /** + * Username (alphanumeric + `_` or `-`, 3–16 chars) + */ + nickname?: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description / bio + */ + user_desc?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)`, + 401: `Unauthorized — missing or invalid authentication token`, + 403: `Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)`, + 404: `User not found`, + 409: `Conflict — e.g., requested \`nickname\` or \`mail\` already taken by another user`, + 422: `Unprocessable Entity — semantic errors not caught by schema (e.g., invalid \`avatar_id\`)`, + 500: `Unknown server error`, + }, + }); + } /** * Get user titles * @param userId * @param cursor + * @param sort + * @param sortForward * @param word - * @param status + * @param status List of title statuses to filter * @param watchStatus * @param rating + * @param myRate * @param releaseYear * @param releaseSeason * @param limit * @param fields - * @returns UserTitle List of user titles + * @returns any List of user titles * @throws ApiError */ public static getUsersTitles( userId: string, cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, word?: string, - status?: TitleStatus, - watchStatus?: UserTitleStatus, + status?: Array, + watchStatus?: Array, rating?: number, + myRate?: number, releaseYear?: number, releaseSeason?: ReleaseSeason, limit: number = 10, fields: string = 'all', - ): CancelablePromise> { + ): CancelablePromise<{ + data: Array; + cursor: CursorObj; + }> { return __request(OpenAPI, { method: 'GET', - url: '/users/{user_id}/titles/', + url: '/users/{user_id}/titles', path: { 'user_id': userId, }, query: { 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, 'word': word, 'status': status, 'watch_status': watchStatus, 'rating': rating, + 'my_rate': myRate, 'release_year': releaseYear, 'release_season': releaseSeason, 'limit': limit, @@ -175,4 +242,43 @@ export class DefaultService { }, }); } + /** + * Add a title to a user + * User adding title to list af watched, status required + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns any Title successfully added to user + * @throws ApiError + */ + public static addUserTitle( + userId: number, + requestBody: UserTitle, + ): CancelablePromise<{ + data?: { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; + }; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to assign titles to this user`, + 404: `User or Title not found`, + 409: `Conflict — title already assigned to user (if applicable)`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts index bab9c77..94578d8 100644 --- a/modules/frontend/src/auth/services/AuthService.ts +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -41,9 +41,9 @@ export class AuthService { pass: string; }, ): CancelablePromise<{ - success?: boolean; error?: string | null; user_id?: string | null; + user_name?: string | null; }> { return __request(OpenAPI, { method: 'POST', diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index dcd6965..89ee88c 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -18,17 +18,19 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { const res = await AuthService.postAuthSignIn({ nickname, pass: password }); - if (res.success) { - // TODO: сохранить JWT в localStorage/cookie - console.log("Logged in user id:", res.user_id); - navigate("/"); // редирект после успешного входа + if (res.user_id && res.user_name) { + // Сохраняем user_id и username в localStorage + localStorage.setItem("userId", res.user_id); + localStorage.setItem("username", res.user_name); + + navigate("/profile"); // редирект на профиль } else { setError(res.error || "Login failed"); } } else { + // SignUp оставляем без сохранения данных const res = await AuthService.postAuthSignUp({ nickname, pass: password }); - if (res.success) { - console.log("User signed up with id:", res.user_id); + if (res.user_id) { setIsLogin(true); // переключаемся на login после регистрации } else { setError(res.error || "Sign up failed"); diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx new file mode 100644 index 0000000..b5a8336 --- /dev/null +++ b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx @@ -0,0 +1,183 @@ +// pages/UserPage/UserPage.tsx +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { DefaultService } from "../../api/services/DefaultService"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { ListView } from "../../components/ListView/ListView"; +import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; +import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; +import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; + +const PAGE_SIZE = 10; + +type UsersIdPageProps = { + userId?: string; +}; + +export default function UsersIdPage({ userId }: UsersIdPageProps) { + const params = useParams(); + const id = userId || params?.id; + + const [user, setUser] = useState(null); + const [loadingUser, setLoadingUser] = useState(true); + const [errorUser, setErrorUser] = useState(null); + + // Для списка тайтлов + const [titles, setTitles] = useState([]); + const [nextPage, setNextPage] = useState([]); + const [cursor, setCursor] = useState(null); + const [loadingTitles, setLoadingTitles] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); + + // --- Получение данных пользователя --- + useEffect(() => { + const fetchUser = async () => { + if (!id) return; + setLoadingUser(true); + try { + const result = await DefaultService.getUsers(id, "all"); + setUser(result); + setErrorUser(null); + } catch (err: any) { + console.error(err); + setErrorUser(err?.message || "Failed to fetch user data"); + } finally { + setLoadingUser(false); + } + }; + fetchUser(); + }, [id]); + + // --- Получение списка тайтлов пользователя --- + const fetchPage = async (cursorObj: CursorObj | null) => { + if (!id) return { items: [], nextCursor: null }; + const cursorStr = cursorObj + ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + : ""; + + try { + const result = await DefaultService.getUsersTitles( + id, + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, // status фильтр, можно добавить + undefined, // watchStatus + undefined, // rating + undefined, // myRate + undefined, // releaseYear + undefined, // releaseSeason + PAGE_SIZE, + "all" + ); + + if (!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; + } + }; + + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoadingTitles(true); + setTitles([]); + setNextPage([]); + setCursor(null); + + 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); + setLoadingTitles(false); + }; + initLoad(); + }, [id, 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); + } + } + + setLoadingMore(false); + }; + + const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); + + return ( +
+ + {/* --- Карточка пользователя --- */} + {loadingUser &&
Loading user...
} + {errorUser &&
{errorUser}
} + {user && ( +
+ {user.nickname} +

{user.disp_name || user.nickname}

+ {user.mail &&

{user.mail}

} + {user.user_desc &&

{user.user_desc}

} + {user.creation_date &&

Registered: {new Date(user.creation_date).toLocaleDateString()}

} +
+ )} + + {/* --- Панель поиска, сортировки и лейаута --- */} +
+ + + +
+ + {/* --- Список тайтлов --- */} + {loadingTitles &&
Loading titles...
} + {!loadingTitles && 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} тайтлов. +
+ )} + + )} +
+ ); +}