diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d403a77..275b2e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,50 +1,47 @@ -import { MINIMUM_WIDTH } from "./pages/app/core/config"; -import { isElectron } from "./pages/app/electron/electron"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { ElectronTitleBar } from "./pages/app/ui/components/Electron"; -import useWindowSize from "./pages/app/ui/hooks/useWindowSize"; -import ChatScreen from "./pages/app/ui/screen/ChatScreen"; -import DownloadAppScreen from "./pages/app/ui/screen/DownloadAppScreen"; -import LoginScreen from "./pages/app/ui/screen/LoginScreen"; -import RegisterScreen from "./pages/app/ui/screen/RegisterScreen"; import { useAppState } from "./pages/app/ui/state"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import LoginPage from "./pages/LoginPage"; +import RegisterPage from "./pages/RegisterPage"; +import ChatPage from "./pages/ChatPage"; +import NotFoundPage from "./pages/NotFoundPage"; +import ProtectedRoute from "./pages/ProtectedRoute"; +import { isElectron } from "./pages/app/electron/electron"; +import { MINIMUM_WIDTH } from "./pages/app/core/config"; +import useWindowSize from "./pages/app/ui/hooks/useWindowSize"; export default function App() { - const { currentPage, restoreUserFromStorage } = useAppState(); + const { restoreUserFromStorage } = useAppState(); const { width } = useWindowSize(); + const [authReady, setAuthReady] = useState(false); // Restore user from localStorage on app initialization useEffect(() => { - restoreUserFromStorage(); + restoreUserFromStorage().finally(() => { + setAuthReady(true); + }); }, [restoreUserFromStorage]); - if (!isElectron && width < MINIMUM_WIDTH) { - return - } - - let page = ; - - switch (currentPage) { - case "login": { - page = - break; - } - case "register": { - page = - break; - } - case "chat": { - page = - break; - } - } - - return ( - <> + return authReady && ( + + {!isElectron && width < MINIMUM_WIDTH && }
- {page} + + } /> + } /> + } /> + + + + + } /> + + } /> +
- +
) } \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 572bfd4..2296930 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,7 +5,7 @@ * @version 1.0.0 */ -import './resources/css/style.scss'; +import './pages/app/resources/css/style.scss'; import "mdui/mdui.css"; import "./pages/app/utils/material"; diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx new file mode 100644 index 0000000..0989a42 --- /dev/null +++ b/frontend/src/pages/ChatPage.tsx @@ -0,0 +1,13 @@ +import { LeftPanel } from "./app/ui/components/chat/LeftPanel"; +import { RightPanel } from "./app/ui/components/chat/RightPanel"; + +export default function ChatPage() { + return ( +
+
+ + +
+
+ ); +} diff --git a/frontend/src/pages/DownloadAppPage.tsx b/frontend/src/pages/DownloadAppPage.tsx new file mode 100644 index 0000000..3e8ec2e --- /dev/null +++ b/frontend/src/pages/DownloadAppPage.tsx @@ -0,0 +1,25 @@ +export default function DownloadAppPage() { + return ( +
+
+

Чтобы пользоваться мессенджером, скачайте приложение

+

+ Этот сайт не предназначен для работы на маленьких экранах, поэтому + вам нужно скачать приложение мессенджера. +

+ + + Скачать на GitHub + + +

+ Если возникнут сложности или есть вопросы, нажмите кнопку! +

+ + + Написать в поддержку + +
+
+ ) +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..0fdb5b4 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,142 @@ +import { useImmer } from "use-immer"; +import { AlertsContainer, type Alert, type AlertType } from "./app/ui/components/Alerts"; +import { AuthContainer, AuthHeader } from "./app/ui/components/Auth"; +import type { ErrorResponse, LoginRequest, LoginResponse } from "./app/core/types"; +import { ensureKeysOnLogin } from "./app/auth/crypto"; +import { API_BASE_URL } from "./app/core/config"; +import { useRef } from "react"; +import type { TextField } from "mdui/components/text-field"; +import { useAppState } from "./app/ui/state"; +import { MaterialTextField } from "./app/ui/components/core/TextField"; +import { initialize, isSupported, startElectronReceiver, subscribe } from "./app/utils/push-notifications"; +import { isElectron } from "./app/electron/electron"; +import { useNavigate } from "react-router-dom"; + +export default function LoginPage() { + const [alerts, updateAlerts] = useImmer([]); + const setUser = useAppState(state => state.setUser); + const navigate = useNavigate(); + + function showAlert(type: AlertType, message: string) { + updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); + } + + const usernameElement = useRef(null); + const passwordElement = useRef(null); + + return ( + + +
+ + +
{ + e.preventDefault(); + + const username = usernameElement.current!.value.trim(); + const password = passwordElement.current!.value.trim(); + + if (!username || !password) { + showAlert("danger", "Пожалуйста, заполните все поля"); + return; + } + + try { + const request: LoginRequest = { + username: username, + password: password + } + + const response = await fetch(`${API_BASE_URL}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request) + }); + + if (response.ok) { + const data: LoginResponse = await response.json(); + // Store the JWT token first + setUser(data.token, data.user); + + // Setup keys with the token we just received + try { + await ensureKeysOnLogin(password, data.token); + } catch (e) { + console.error("Key setup failed:", e); + } + + navigate("/chat"); + + // Initialize notifications + try { + if (isSupported()) { + const initialized = await initialize(); + if (initialized) { + await subscribe(data.token); + + // For Electron, start the notification receiver + if (isElectron) { + await startElectronReceiver(); + } + + console.log("Notifications enabled"); + } else { + console.log("Notification permission denied"); + } + } else { + console.log("Notifications not supported"); + } + } catch (e) { + console.error("Notification setup failed:", e); + } + } else { + const data: ErrorResponse = await response.json(); + showAlert("danger", data.message || "Неверное имя пользователя или пароль"); + } + } catch (error) { + showAlert("danger", "Ошибка соединения с сервером"); + } + }}> + + + + + Войти + + + +
+
+ ) +} diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..907119f --- /dev/null +++ b/frontend/src/pages/NotFoundPage.tsx @@ -0,0 +1,36 @@ +import { useNavigate } from "react-router-dom"; + +export default function NotFoundPage() { + const navigate = useNavigate(); + + return ( +
+
+
+
404
+

Страница не найдена

+

+ К сожалению, запрашиваемая страница не существует или была перемещена. +

+
+ navigate("/")} + > + На главную + + navigate(-1)} + > + Назад + +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/ProtectedRoute.tsx b/frontend/src/pages/ProtectedRoute.tsx new file mode 100644 index 0000000..40c4652 --- /dev/null +++ b/frontend/src/pages/ProtectedRoute.tsx @@ -0,0 +1,21 @@ +import { useEffect } from "react"; +import { useAppState } from "./app/ui/state"; +import { useNavigate } from "react-router-dom"; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export default function ProtectedRoute({ children }: ProtectedRouteProps) { + const { user } = useAppState(); + const navigate = useNavigate(); + + useEffect(() => { + if (!user.authToken) { + navigate("/login"); + return; + } + }, [user.authToken, user.currentUser, navigate]); + + return <>{children}; +} diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..2e437e1 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,147 @@ +import { useImmer } from "use-immer"; +import { AuthContainer, AuthHeader } from "./app/ui/components/Auth"; +import { AlertsContainer, type Alert, type AlertType } from "./app/ui/components/Alerts"; +import { useRef } from "react"; +import { TextField } from "mdui/components/text-field"; +import type { ErrorResponse, RegisterRequest, LoginResponse } from "./app/core/types"; +import { API_BASE_URL } from "./app/core/config"; +import { useAppState } from "./app/ui/state"; +import { MaterialTextField } from "./app/ui/components/core/TextField"; +import { ensureKeysOnLogin } from "./app/auth/crypto"; +import { useNavigate } from "react-router-dom"; + +export default function RegisterPage() { + const [alerts, updateAlerts] = useImmer([]); + const setUser = useAppState(state => state.setUser); + const navigate = useNavigate(); + + function showAlert(type: AlertType, message: string) { + updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); + } + + const usernameElement = useRef(null); + const passwordElement = useRef(null); + const confirmPasswordElement = useRef(null); + + return ( + + +
+ + +
{ + e.preventDefault(); + + const username = usernameElement.current!.value.trim(); + const password = passwordElement.current!.value.trim(); + const confirmPassword = confirmPasswordElement.current!.value.trim(); + + if (!username || !password || !confirmPassword) { + showAlert("danger", "Пожалуйста, заполните все поля"); + return; + } + + if (password !== confirmPassword) { + showAlert("danger", "Пароли не совпадают"); + return; + } + + if (username.length < 3 || username.length > 20) { + showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов"); + return; + } + + if (password.length < 5 || password.length > 50) { + showAlert("danger", "Пароль должен быть от 5 до 50 символов"); + return; + } + + try { + const request: RegisterRequest = { + username: username, + password: password, + confirm_password: confirmPassword + } + + const response = await fetch(`${API_BASE_URL}/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request) + }); + + if (response.ok) { + const data: LoginResponse = await response.json(); + // Store the JWT token first + setUser(data.token, data.user); + + // Setup keys with the token we just received + try { + await ensureKeysOnLogin(password, data.token); + } catch (e) { + console.error("Key setup failed:", e); + } + + navigate("/chat"); + } else { + const data: ErrorResponse = await response.json(); + showAlert("danger", data.message || "Ошибка при регистрации"); + } + } catch (error) { + showAlert("danger", "Ошибка соединения с сервером"); + } + }}> + + + + + Зарегистрироваться + + +
+

+ Уже есть аккаунт? + navigate("/login")}> + Войдите + +

+
+
+
+ ) +} diff --git a/frontend/src/pages/app/resources/css/_404.scss b/frontend/src/pages/app/resources/css/_404.scss new file mode 100644 index 0000000..d91252f --- /dev/null +++ b/frontend/src/pages/app/resources/css/_404.scss @@ -0,0 +1,84 @@ +.not-found-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 2rem; +} + +.not-found-container { + display: flex; + align-items: center; + gap: 3rem; + max-width: 800px; + width: 100%; + background: rgba(255, 255, 255, 0.95); + border-radius: 20px; + padding: 3rem; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); +} + +.not-found-content { + flex: 1; +} + +.error-code { + font-size: 6rem; + font-weight: 900; + color: #667eea; + line-height: 1; + margin-bottom: 1rem; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); +} + +.not-found-content h1 { + font-size: 2.5rem; + font-weight: 700; + color: #2d3748; + margin-bottom: 1rem; + line-height: 1.2; +} + +.not-found-content p { + font-size: 1.1rem; + color: #718096; + margin-bottom: 2rem; + line-height: 1.6; +} + +.not-found-actions { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.not-found-illustration { + display: flex; + align-items: center; + justify-content: center; + color: #667eea; + opacity: 0.7; +} + +@media (max-width: 768px) { + .not-found-container { + flex-direction: column; + text-align: center; + gap: 2rem; + padding: 2rem; + } + + .error-code { + font-size: 4rem; + } + + .not-found-content h1 { + font-size: 2rem; + } + + .not-found-actions { + justify-content: center; + } +} diff --git a/frontend/src/pages/app/resources/css/style.scss b/frontend/src/pages/app/resources/css/style.scss index 4cfe673..3e06041 100644 --- a/frontend/src/pages/app/resources/css/style.scss +++ b/frontend/src/pages/app/resources/css/style.scss @@ -10,6 +10,7 @@ @use "electron"; @use "dialogs/reply"; @use "download-app"; +@use "404" as not-found; @use "lib/fonts/montserrat"; @use "lib/fonts/material-symbols"; diff --git a/frontend/src/pages/app/ui/screen/LoginScreen.tsx b/frontend/src/pages/app/ui/screen/LoginScreen.tsx index 06df52f..168b0d8 100644 --- a/frontend/src/pages/app/ui/screen/LoginScreen.tsx +++ b/frontend/src/pages/app/ui/screen/LoginScreen.tsx @@ -7,14 +7,15 @@ import { API_BASE_URL } from "../../core/config"; import { useRef } from "react"; import type { TextField } from "mdui/components/text-field"; import { useAppState } from "../state"; +import { useNavigate } from "react-router-dom"; import { MaterialTextField } from "../components/core/TextField"; import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/push-notifications"; import { isElectron } from "../../electron/electron"; export default function LoginScreen() { const [alerts, updateAlerts] = useImmer([]); - const setCurrentPage = useAppState(state => state.setCurrentPage); const setUser = useAppState(state => state.setUser); + const navigate = useNavigate(); function showAlert(type: AlertType, message: string) { updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); @@ -67,7 +68,7 @@ export default function LoginScreen() { console.error("Key setup failed:", e); } - setCurrentPage("chat"); + navigate("/chat"); // Initialize notifications try { @@ -130,7 +131,7 @@ export default function LoginScreen() { setCurrentPage("register")}> + onClick={() => navigate("/register")}> Зарегистрируйтесь

diff --git a/frontend/src/pages/app/ui/screen/RegisterScreen.tsx b/frontend/src/pages/app/ui/screen/RegisterScreen.tsx index a93efd3..ec16814 100644 --- a/frontend/src/pages/app/ui/screen/RegisterScreen.tsx +++ b/frontend/src/pages/app/ui/screen/RegisterScreen.tsx @@ -7,13 +7,14 @@ import { TextField } from "mdui/components/text-field"; import type { ErrorResponse, RegisterRequest, LoginResponse } from "../../core/types"; import { API_BASE_URL } from "../../core/config"; import { useAppState } from "../state"; +import { useNavigate } from "react-router-dom"; import { MaterialTextField } from "../components/core/TextField"; import { ensureKeysOnLogin } from "../../auth/crypto"; export default function RegisterScreen() { const [alerts, updateAlerts] = useImmer([]); - const setCurrentPage = useAppState(state => state.setCurrentPage); const setUser = useAppState(state => state.setUser); + const navigate = useNavigate(); function showAlert(type: AlertType, message: string) { updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); @@ -83,7 +84,7 @@ export default function RegisterScreen() { console.error("Key setup failed:", e); } - setCurrentPage("chat"); + navigate("/chat"); } else { const data: ErrorResponse = await response.json(); showAlert("danger", data.message || "Ошибка при регистрации"); @@ -136,7 +137,7 @@ export default function RegisterScreen() { href="#" id="login-link" className="link" - onClick={() => setCurrentPage("login")}> + onClick={() => navigate("/login")}> Войдите

diff --git a/frontend/src/pages/app/ui/state.ts b/frontend/src/pages/app/ui/state.ts index bf31eb4..c5db87e 100644 --- a/frontend/src/pages/app/ui/state.ts +++ b/frontend/src/pages/app/ui/state.ts @@ -10,7 +10,6 @@ import { API_BASE_URL } from "../core/config"; import { initialize, subscribe, startElectronReceiver, isSupported } from "../utils/push-notifications"; import { isElectron } from "../electron/electron"; -type Page = "login" | "register" | "chat" export type ChatTabs = "chats" | "channels" | "contacts" | "dms" interface ActiveDM { @@ -37,9 +36,6 @@ export interface UserState { } interface AppState { - currentPage: Page; - setCurrentPage: (page: Page) => void; - // Chat state chat: ChatState; addMessage: (message: Message) => void; @@ -60,13 +56,10 @@ interface AppState { user: UserState; setUser: (token: string, user: User) => void; logout: () => void; - restoreUserFromStorage: () => void; + restoreUserFromStorage: () => Promise; } export const useAppState = create((set, get) => ({ - currentPage: "login", // default page - setCurrentPage: (page: Page) => set({ currentPage: page }), - // Chat state chat: { messages: [], @@ -191,8 +184,7 @@ export const useAppState = create((set, get) => ({ user: { currentUser: null, authToken: null - }, - currentPage: "login" + } })); }, restoreUserFromStorage: async () => { @@ -212,8 +204,7 @@ export const useAppState = create((set, get) => ({ user: { currentUser: user, authToken: token - }, - currentPage: "chat" + } })); try { diff --git a/package.json b/package.json index 7a1dd91..ab204c5 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "mdui": "^2.1.4", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-router-dom": "^7.9.3", "tweetnacl": "^1.0.3", "use-immer": "^0.11.0", "zustand": "^5.0.8"