diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 9e62c13..aa45a43 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -73,8 +73,10 @@ services: # ports: # - "8080:8080" depends_on: - - postgres - - rabbitmq + postgres: + condition: service_started + rabbitmq: + condition: service_healthy networks: - nyanimedb-network diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf index 6075999..875c570 100644 --- a/modules/frontend/nginx-default.conf +++ b/modules/frontend/nginx-default.conf @@ -30,6 +30,9 @@ server { } location /media/ { + limit_except GET { + deny all; + } rewrite ^/media/(.*)$ /$1 break; proxy_pass http://nyanimedb-images:8000/; proxy_http_version 1.1; diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index de7101c..f8ebdf5 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -5,6 +5,7 @@ import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; +import { SettingsPage } from "./pages/SettingsPage/SettingsPage"; import { Header } from "./components/Header/Header"; // import { OpenAPI } from "./api"; @@ -46,6 +47,12 @@ const App: React.FC = () => { element={userId ? : } /> + {/*settings*/} + : } + /> + {/* titles */} } /> } /> diff --git a/modules/frontend/src/api/AuthClient/AuthClient.ts b/modules/frontend/src/api/AuthClient/AuthClient.ts new file mode 100644 index 0000000..c7c20dd --- /dev/null +++ b/modules/frontend/src/api/AuthClient/AuthClient.ts @@ -0,0 +1,64 @@ +import { createClient, createConfig } from "../client"; +import type { ClientOptions as ClientOptions2 } from '../types.gen'; +import type { Client, RequestOptions, RequestResult } from "../client"; +import { refreshTokens } from "../../auth"; +import type { ResponseStyle } from "../client"; + +let refreshPromise: Promise | null = null; + +async function getRefreshed(): Promise { + if (!refreshPromise) { + refreshPromise = (async () => { + try { + const res = await refreshTokens({ throwOnError: true }); + return !!res.data; + } catch { + return false; + } + })(); + } + return refreshPromise; +} + +const baseClient = createClient(createConfig({ baseUrl: '/api/v1' })); + +export const authClient: Client = { + ...baseClient, + + request: (async < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', + >( + options: Omit, 'method'> & + Pick>, 'method'> + ): Promise> => { + + let result = await baseClient.request(options); + + // 1. Cast to a Record to allow the 'in' operator check on a generic + // We use 'unknown' instead of 'any' to maintain safety. + const resultObj = result as Record; + + // 2. Check if the object is valid and contains the error key + if (result && typeof result === 'object' && 'error' in resultObj) { + + // 3. Narrow the error property specifically + const error = resultObj.error as { response?: { status?: number } } | null | undefined; + + if (error?.response?.status === 401) { + const refreshed = await getRefreshed(); + + if (refreshed) { + result = await baseClient.request(options); + } else { + localStorage.clear(); + window.location.href = "/login"; + } + } + } + + return result; + }) as Client['request'], +}; \ No newline at end of file diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx index 36cbd5a..9c3b39a 100644 --- a/modules/frontend/src/components/Header/Header.tsx +++ b/modules/frontend/src/components/Header/Header.tsx @@ -32,6 +32,8 @@ export const Header: React.FC = () => { localStorage.removeItem("user_id"); localStorage.removeItem("user_name"); setUsername(null); + setDropdownOpen(false); + setMenuOpen(false); navigate("/login"); } catch (err) { console.error(err); @@ -74,14 +76,38 @@ export const Header: React.FC = () => {
{dropdownOpen && ( -
- setDropdownOpen(false)}>Profile - +
+ setDropdownOpen(false)} + > + Profile + + + {/* КНОПКА SETTINGS */} + setDropdownOpen(false)} + > + Settings + + +
+ +
)}
@@ -107,11 +133,16 @@ export const Header: React.FC = () => { setMenuOpen(false)}>Titles setMenuOpen(false)}>Users setMenuOpen(false)}>About + {username ? ( - <> - setMenuOpen(false)}>Profile - - +
+ setMenuOpen(false)}>Profile + + {/* SETTINGS (Mobile) */} + setMenuOpen(false)}>Settings + + +
) : ( setMenuOpen(false)}>Login )} diff --git a/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx b/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx index 16c7e9e..f525baf 100644 --- a/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx +++ b/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx @@ -1,154 +1,235 @@ -// import React, { useEffect, useState } from "react"; -// import { updateUser, getUsersId } from "../../api"; -// import { useNavigate } from "react-router-dom"; +import React, { useEffect, useState, useRef } from "react"; +import { updateUser, getUsersId } from "../../api"; +import { useNavigate } from "react-router-dom"; +import { useCookies } from 'react-cookie'; -// export const SettingsPage: React.FC = () => { -// const navigate = useNavigate(); +export const SettingsPage: React.FC = () => { + const [cookies] = useCookies(['xsrf_token']); + const xsrfToken = cookies['xsrf_token'] || null; -// const userId = Number(localStorage.getItem("user_id")); -// const initialNickname = localStorage.getItem("user_name") || ""; -// const [mail, setMail] = useState(""); -// const [nickname, setNickname] = useState(initialNickname); -// const [dispName, setDispName] = useState(""); -// const [userDesc, setUserDesc] = useState(""); -// const [avatarId, setAvatarId] = useState(null); + const navigate = useNavigate(); + const fileInputRef = useRef(null); -// const [loading, setLoading] = useState(false); -// const [success, setSuccess] = useState(null); -// const [error, setError] = useState(null); + const userId = Number(localStorage.getItem("user_id")); + + // Состояния для полей формы + const [mail, setMail] = useState(""); + const [nickname, setNickname] = useState(""); + const [dispName, setDispName] = useState(""); + const [userDesc, setUserDesc] = useState(""); + const [avatarId, setAvatarId] = useState(null); + const [avatarUrl, setAvatarUrl] = useState(null); -// useEffect(() => { -// const fetch = async () => { -// const res = await getUsersId({ -// path: { user_id: String(userId) }, -// }); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(null); -// setProfile(res.data); -// }; + // Загружаем текущие данные пользователя при входе + useEffect(() => { + const fetchUserData = async () => { + try { + const res = await getUsersId({ + path: { user_id: String(userId) }, + }); + if (res.data) { + setMail(res.data.mail || ""); + setNickname(res.data.nickname || ""); + setDispName(res.data.disp_name || ""); + setUserDesc(res.data.user_desc || ""); + setAvatarId(res.data.image?.id ?? null); + const path = res.data.image?.image_path; + setAvatarUrl(path ? (path.startsWith('http') ? path : `/media/${path}`) : null); + } + } catch (err) { + console.error("Failed to fetch user:", err); + } + }; + fetchUserData(); + }, [userId]); -// fetch(); -// }, [userId]); + // Обработка загрузки файла +const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; -// const saveSettings = async (e: React.FormEvent) => { -// e.preventDefault(); -// setLoading(true); -// setSuccess(null); -// setError(null); + setUploading(true); + setError(null); + setSuccess(null); -// try { -// const res = await updateUser({ -// path: { user_id: userId }, -// body: { -// ...(mail ? { mail } : {}), -// ...(nickname ? { nickname } : {}), -// ...(dispName ? { disp_name: dispName } : {}), -// ...(userDesc ? { user_desc: userDesc } : {}), -// ...(avatarId !== undefined ? { avatar_id: avatarId } : {}), -// } -// }); + const formData = new FormData(); + formData.append("image", file); -// // Обновляем локальное отображение username -// if (nickname) { -// localStorage.setItem("user_name", nickname); -// window.dispatchEvent(new Event("storage")); // чтобы Header обновился -// } + try { + // 1. Загружаем файл на сервер (POST) + const uploadRes = await fetch("/api/v1/media/upload", { + method: "POST", + body: formData, + headers: { + "X-XSRF-TOKEN": xsrfToken || "", + }, + }); -// setSuccess("Settings updated!"); -// setTimeout(() => navigate("/profile"), 800); + if (!uploadRes.ok) throw new Error("Failed to upload image to storage"); -// } catch (err: any) { -// console.error(err); -// setError(err?.message || "Failed to update settings"); -// } finally { -// setLoading(false); -// } -// }; + const uploadData = await uploadRes.json(); + const newAvatarId = uploadData.id; -// return ( -//
-//

User Settings

+ if (newAvatarId) { + // 2. СРАЗУ отправляем PATCH запрос для обновления профиля пользователя + await updateUser({ + path: { user_id: userId }, + body: { + avatar_id: newAvatarId, // Привязываем новый ID к юзеру + }, + headers: { "X-XSRF-TOKEN": xsrfToken }, + }); -// {success &&
{success}
} -// {error &&
{error}
} + // 3. Обновляем локальный стейт для отображения + setAvatarId(newAvatarId); + const path = uploadData.image_path; + setAvatarUrl(path ? (path.startsWith("/") ? path : `/media/${path}`) : null); + + setSuccess("Avatar updated successfully!"); + } + } catch (err: any) { + console.error("Upload & Patch error:", err); + setError(err.message || "Failed to update avatar"); + } finally { + setUploading(false); + } +}; -//
-// {/* Email */} -//
-// -// setMail(e.target.value)} -// placeholder="example@mail.com" -// className="w-full px-4 py-2 border rounded-lg" -// /> -//
+ const saveSettings = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setSuccess(null); + setError(null); -// {/* Nickname */} -//
-// -// setNickname(e.target.value)} -// className="w-full px-4 py-2 border rounded-lg" -// /> -//
+ try { + await updateUser({ + path: { user_id: userId }, + body: { + mail: mail || undefined, + nickname: nickname || undefined, + disp_name: dispName || undefined, + user_desc: userDesc || undefined, + avatar_id: avatarId, // Может быть числом или null для удаления + }, + headers: { "X-XSRF-TOKEN": xsrfToken }, + }); -// {/* Display name */} -//
-// -// setDispName(e.target.value)} -// placeholder="Shown name" -// className="w-full px-4 py-2 border rounded-lg" -// /> -//
+ localStorage.setItem("user_name", nickname); + window.dispatchEvent(new Event("storage")); -// {/* Bio */} -//
-// -//