Migrate to React Router

This commit is contained in:
2025-10-02 18:35:29 +03:00
Unverified
parent 2995bc96ac
commit aa1fb8c518
14 changed files with 514 additions and 54 deletions
+32 -35
View File
@@ -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 <DownloadAppScreen />
}
let page = <LoginScreen />;
switch (currentPage) {
case "login": {
page = <LoginScreen />
break;
}
case "register": {
page = <RegisterScreen />
break;
}
case "chat": {
page = <ChatScreen />
break;
}
}
return (
<>
return authReady && (
<BrowserRouter>
{!isElectron && width < MINIMUM_WIDTH && <Navigate to="/download-app" replace />}
<ElectronTitleBar />
<div id="main-wrapper">
{page}
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/">
<Route path="chat" element={
<ProtectedRoute>
<ChatPage />
</ProtectedRoute>
} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</>
</BrowserRouter>
)
}
+1 -1
View File
@@ -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";
+13
View File
@@ -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 (
<div id="chat-interface">
<div className="all-container">
<LeftPanel />
<RightPanel />
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
export default function DownloadAppPage() {
return (
<div className="download-app-screen">
<div className="inner">
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
<p>
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
вам нужно скачать приложение мессенджера.
</p>
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
<mdui-button>Скачать на GitHub</mdui-button>
</a>
<p>
Если возникнут сложности или есть вопросы, нажмите кнопку!
</p>
<a href="https://t.me/denis0001-dev">
<mdui-button>Написать в поддержку</mdui-button>
</a>
</div>
</div>
)
}
+142
View File
@@ -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<Alert[]>([]);
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<TextField>(null);
const passwordElement = useRef<TextField>(null);
return (
<AuthContainer>
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
<div className="auth-body">
<AlertsContainer alerts={alerts} />
<form
onSubmit={async (e) => {
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", "Ошибка соединения с сервером");
}
}}>
<MaterialTextField
label="Имя пользователя"
id="login-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="login-password"
name="password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="current-password"
required
ref={passwordElement} />
<mdui-button type="submit">Войти</mdui-button>
</form>
<div className="text-center">
<p>
Ещё нет аккаунта?
<a
href="#"
className="link"
onClick={() => navigate("/register")}>
Зарегистрируйтесь
</a>
</p>
</div>
</div>
</AuthContainer>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { useNavigate } from "react-router-dom";
export default function NotFoundPage() {
const navigate = useNavigate();
return (
<div className="not-found-page">
<div className="not-found-container">
<div className="not-found-content">
<div className="error-code">404</div>
<h1>Страница не найдена</h1>
<p>
К сожалению, запрашиваемая страница не существует или была перемещена.
</p>
<div className="not-found-actions">
<mdui-button
variant="filled"
onClick={() => navigate("/")}
>
На главную
</mdui-button>
<mdui-button
variant="outlined"
onClick={() => navigate(-1)}
>
Назад
</mdui-button>
</div>
</div>
<div className="not-found-illustration">
<mdui-icon name="search_off"></mdui-icon>
</div>
</div>
</div>
);
}
+21
View File
@@ -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}</>;
}
+147
View File
@@ -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<Alert[]>([]);
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<TextField>(null);
const passwordElement = useRef<TextField>(null);
const confirmPasswordElement = useRef<TextField>(null);
return (
<AuthContainer>
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
<div className="auth-body">
<AlertsContainer alerts={alerts} />
<form onSubmit={async (e) => {
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", "Ошибка соединения с сервером");
}
}}>
<MaterialTextField
label="Имя пользователя"
id="register-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
maxlength={20}
counter
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="register-password"
name="password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={passwordElement} />
<MaterialTextField
label="Подтвердите пароль"
id="register-confirm-password"
name="confirm_password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={confirmPasswordElement} />
<mdui-button type="submit">Зарегистрироваться</mdui-button>
</form>
<div className="text-center">
<p>
Уже есть аккаунт?
<a
href="#"
id="login-link"
className="link"
onClick={() => navigate("/login")}>
Войдите
</a>
</p>
</div>
</div>
</AuthContainer>
)
}
@@ -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;
}
}
@@ -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";
@@ -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<Alert[]>([]);
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() {
<a
href="#"
className="link"
onClick={() => setCurrentPage("register")}>
onClick={() => navigate("/register")}>
Зарегистрируйтесь
</a>
</p>
@@ -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<Alert[]>([]);
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")}>
Войдите
</a>
</p>
+3 -12
View File
@@ -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<void>;
}
export const useAppState = create<AppState>((set, get) => ({
currentPage: "login", // default page
setCurrentPage: (page: Page) => set({ currentPage: page }),
// Chat state
chat: {
messages: [],
@@ -191,8 +184,7 @@ export const useAppState = create<AppState>((set, get) => ({
user: {
currentUser: null,
authToken: null
},
currentPage: "login"
}
}));
},
restoreUserFromStorage: async () => {
@@ -212,8 +204,7 @@ export const useAppState = create<AppState>((set, get) => ({
user: {
currentUser: user,
authToken: token
},
currentPage: "chat"
}
}));
try {
+1
View File
@@ -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"