From 59472ea1d764df9746f7b40ced2714a8a089bb8f Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 7 Nov 2025 11:44:27 +0300 Subject: [PATCH] Redesign the UI --- .cursor/rules/general.mdc | 5 +- frontend/src/App.tsx | 68 +++- frontend/src/css/_components.scss | 75 ---- frontend/src/css/_material.scss | 87 ++--- frontend/src/pages/auth/Auth.tsx | 117 +++++- frontend/src/pages/auth/AuthPage.tsx | 164 +++++++++ frontend/src/pages/auth/AuthTextField.tsx | 138 ++++++++ frontend/src/pages/auth/LoginForm.tsx | 220 ++++++++++++ frontend/src/pages/auth/LoginPage.tsx | 155 -------- frontend/src/pages/auth/RegisterForm.tsx | 249 +++++++++++++ frontend/src/pages/auth/RegisterPage.tsx | 173 --------- frontend/src/pages/auth/auth.module.scss | 333 ++++++++++++++++-- .../src/pages/chat/css/ChatInput.module.scss | 2 +- .../src/pages/chat/css/Message.module.scss | 4 +- .../src/pages/chat/css/callWindow.module.scss | 4 - .../src/pages/chat/css/layout.module.scss | 7 +- .../src/pages/chat/css/left-panel.module.scss | 6 +- .../pages/chat/css/profile-dialog.module.scss | 3 + frontend/src/pages/home/home.module.scss | 10 +- .../src/pages/not-found/not-found.module.scss | 6 +- frontend/src/utils/material.tsx | 2 +- 21 files changed, 1306 insertions(+), 522 deletions(-) create mode 100644 frontend/src/pages/auth/AuthPage.tsx create mode 100644 frontend/src/pages/auth/AuthTextField.tsx create mode 100644 frontend/src/pages/auth/LoginForm.tsx delete mode 100644 frontend/src/pages/auth/LoginPage.tsx create mode 100644 frontend/src/pages/auth/RegisterForm.tsx delete mode 100644 frontend/src/pages/auth/RegisterPage.tsx diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 9c92b16..4d5ccf7 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -55,4 +55,7 @@ When working with this project, follow these rules: ## Styling - Use SCSS modules - Use nested styles -- Put SCSS into one folder per page \ No newline at end of file +- Put SCSS into one folder per page + +## Animations with Framer Motion +- Don't use variants if they are used only once \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7cb4c96..8cb4602 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,23 +1,25 @@ -import { BrowserRouter, Routes, Route, useNavigate, matchRoutes, type RouteObject } from "react-router-dom"; +import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom"; +import { AnimatePresence, motion } from "motion/react"; import { ElectronTitleBar } from "./Electron"; import { useAppState } from "./pages/chat/state"; -import { lazy, useEffect, useState } from "react"; +import { lazy, useEffect, useRef, useState } from "react"; import { parseProfileLink } from "./core/profileLinks"; import NotFoundPage from "./pages/not-found/NotFoundPage"; import ProtectedRoute from "./pages/ProtectedRoute"; import DownloadAppPage from "./pages/download-app/DownloadAppPage"; import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog"; +import { delay } from "./utils/utils"; // Lazy load route components const HomePage = lazy(() => import("./pages/home/HomePage")); -const LoginPage = lazy(() => import("./pages/auth/LoginPage")); -const RegisterPage = lazy(() => import("./pages/auth/RegisterPage")); +const AuthPage = lazy(() => import("./pages/auth/AuthPage")); const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage")); const routeConfig: RouteObject[] = [ { path: "/", element: }, - { path: "/login", element: }, - { path: "/register", element: }, + { path: "/auth", element: }, + { path: "/login", element: }, + { path: "/register", element: }, { path: "/download-app", element: }, { path: "/chat", @@ -66,6 +68,54 @@ function SmartCatchAll() { } } +function AnimatedRoutes() { + const location = useLocation(); + const prevPathnameRef = useRef(location.pathname); + + return ( + + { + if (prevPathnameRef.current !== location.pathname) { + prevPathnameRef.current = location.pathname; + document.body.style.overflow = "hidden"; + } + }} + onAnimationComplete={async () => { + await delay(500); + document.body.style.overflow = ""; + }} + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 1, scale: 1.1 }} + transition={{ + type: "spring", + stiffness: 300, + damping: 30, + mass: 0.8 + }} + style={{ + transformOrigin: "center center", + width: "100%", + height: "100%", + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0 + }} + > + + {routeConfig.map((route, index) => ( + + ))} + + + + ); +} + export default function App() { const { restoreUserFromStorage, user } = useAppState(); const [authReady, setAuthReady] = useState(false); @@ -80,11 +130,7 @@ export default function App() {
- - {routeConfig.map((route, index) => ( - - ))} - +
{user.isSuspended && ( -
+
+ {children} -
+
) } @@ -29,13 +47,63 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) { const iconName = typeof icon == "string" ? icon : icon.name; return ( -
+

- {iconName} + + {iconName} + {title}

-

{subtitle}

-
+ + {subtitle} + + ) } @@ -47,11 +115,40 @@ export interface Alert { } export function AlertsContainer({ alerts }: { alerts: Alert[]}) { + const displayAlerts = alerts.slice(-3); + return ( -
- {alerts.slice(-3).map((alert, i) => { - return
{alert.message}
- })} +
+ + {displayAlerts.map((alert, i) => ( + + {alert.message} + + ))} +
) } \ No newline at end of file diff --git a/frontend/src/pages/auth/AuthPage.tsx b/frontend/src/pages/auth/AuthPage.tsx new file mode 100644 index 0000000..ae5f888 --- /dev/null +++ b/frontend/src/pages/auth/AuthPage.tsx @@ -0,0 +1,164 @@ +import { AuthContainer } from "./Auth"; +import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { motion, AnimatePresence } from "motion/react"; +import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; +import { LoginForm } from "./LoginForm"; +import { RegisterForm } from "./RegisterForm"; +import type { Variants, Transition } from "motion/react"; +import styles from "./auth.module.scss"; + +const slideVariants: Variants = { + enter: (direction: number) => ({ + x: direction > 0 ? 300 : -300, + opacity: 0 + }), + center: { + x: 0, + opacity: 1 + }, + exit: (direction: number) => ({ + x: direction > 0 ? -300 : 300, + opacity: 0 + }) +}; + +const slideTransition: Transition = { + x: { + type: "spring", + stiffness: 300, + damping: 30 + }, + opacity: { duration: 0.2 } +}; + + + +export default function AuthPage() { + const [searchParams] = useSearchParams(); + const { navigate: navigateDownloadApp } = useDownloadAppScreen(); + if (navigateDownloadApp) return navigateDownloadApp; + const navigate = useNavigate(); + + const [direction, setDirection] = useState(0); + const prevMode = useRef(searchParams.get("mode") || "login"); + const containerRef = useRef(null); + const loginFormRef = useRef(null); + const registerFormRef = useRef(null); + const [containerHeight, setContainerHeight] = useState("auto"); + const currentMode = searchParams.get("mode") || "login"; + const enteringElementRef = useRef<"login" | "register" | null>(null); + + useEffect(() => { + if (prevMode.current !== currentMode) { + setDirection(currentMode === "register" ? 1 : -1); + prevMode.current = currentMode; + enteringElementRef.current = currentMode as "login" | "register"; + } + }, [currentMode]); + + const measureActiveHeight = useCallback(() => { + const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current; + if (activeComponent) { + const height = activeComponent.scrollHeight; + if (height > 0) { + setContainerHeight(height); + } + } + }, [currentMode, loginFormRef, registerFormRef]); + + useLayoutEffect(() => { + // Always measure, but prioritize the entering element during transitions + // Use double requestAnimationFrame to ensure DOM is fully updated and layout is complete + let rafId2: number | null = null; + const rafId1 = requestAnimationFrame(() => { + rafId2 = requestAnimationFrame(() => { + measureActiveHeight(); + }); + }); + + return () => { + cancelAnimationFrame(rafId1); + if (rafId2 !== null) { + cancelAnimationFrame(rafId2); + } + }; + }, [currentMode]); + + function switchMode(newMode: "login" | "register") { + navigate(`/auth?mode=${newMode}`, { replace: true }); + } + + function handleAnimationComplete( + currentMode: "login" | "register", + mode: "login" | "register", + enteringElementRef: RefObject<"login" | "register" | null>, + formRef: React.RefObject, + setContainerHeight: (height: number) => void + ) { + return () => { + if (currentMode === mode && enteringElementRef.current === mode) { + enteringElementRef.current = null; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (formRef.current && currentMode === mode) { + const height = formRef.current.scrollHeight; + if (height > 0) { + setContainerHeight(height); + } + } + }); + }); + } + } + } + + return ( + +
+ + {currentMode === "login" ? ( + + switchMode("register")} /> + + ) : ( + + switchMode("login")} /> + + )} + +
+
+ ) +} diff --git a/frontend/src/pages/auth/AuthTextField.tsx b/frontend/src/pages/auth/AuthTextField.tsx new file mode 100644 index 0000000..6b6f8fe --- /dev/null +++ b/frontend/src/pages/auth/AuthTextField.tsx @@ -0,0 +1,138 @@ +import { forwardRef, useImperativeHandle, useRef, useState, useEffect } from "react"; +import { motion } from "motion/react"; +import styles from "./auth.module.scss"; + +export interface AuthTextFieldHandle { + value: string; + focus: () => void; + blur: () => void; +} + +export interface AuthTextFieldProps { + label: string; + name?: string; + type?: string; + icon?: string; + autocomplete?: string; + required?: boolean; + maxlength?: number; + counter?: boolean; + "toggle-password"?: boolean; + defaultValue?: string; + value?: string; + onChange?: (value: string) => void; + className?: string; +} + +export const AuthTextField = forwardRef( + ({ + label, + name, + type = "text", + icon, + autocomplete, + required = false, + maxlength, + counter = false, + "toggle-password": togglePassword = false, + defaultValue = "", + value: controlledValue, + onChange, + className = "" + }, ref) => { + const [internalValue, setInternalValue] = useState(defaultValue); + const [isFocused, setIsFocused] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [charCount, setCharCount] = useState(0); + const inputRef = useRef(null); + + const isControlled = controlledValue !== undefined; + const value = isControlled ? controlledValue : internalValue; + const displayType = togglePassword && type === "password" ? (showPassword ? "text" : "password") : type; + + useEffect(() => { + if (!isControlled) { + setInternalValue(defaultValue); + } + }, [defaultValue, isControlled]); + + useEffect(() => { + setCharCount(value.length); + }, [value]); + + useImperativeHandle(ref, () => ({ + get value() { + return value; + }, + focus: () => { + inputRef.current?.focus(); + }, + blur: () => { + inputRef.current?.blur(); + } + })); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (!isControlled) { + setInternalValue(newValue); + } + onChange?.(newValue); + }; + + const hasError = false; // Can be extended for validation + + return ( + +
+ {icon && ( + + {icon.replace("--filled", "").replace("--outlined", "")} + + )} +
+ setIsFocused(true)} + onBlur={() => setIsFocused(false)} + autoComplete={autocomplete} + required={required} + maxLength={maxlength} + placeholder={label + (required ? " *" : "")} + className={styles.input} + /> +
+ {togglePassword && type === "password" && ( + + )} +
+ {counter && maxlength && ( +
+ {charCount} / {maxlength} +
+ )} +
+ ); + } +); + +AuthTextField.displayName = "AuthTextField"; diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx new file mode 100644 index 0000000..be6b091 --- /dev/null +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -0,0 +1,220 @@ +import { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { motion, type Transition, type Variants } from "motion/react"; +import { useImmer } from "use-immer"; +import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types"; +import { API_BASE_URL } from "@/core/config"; +import { useAppState } from "@/pages/chat/state"; +import { MaterialButton } from "@/utils/material"; +import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; +import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; +import { isElectron } from "@/core/electron/electron"; +import type { Alert, AlertType } from "./Auth"; +import { AuthHeader, AlertsContainer } from "./Auth"; +import styles from "./auth.module.scss"; + +const loginFieldVariants: Variants = { + initial: { + opacity: 0, + y: 10 + }, + animate: { + opacity: 1, + y: 0 + } +}; + +const loginFieldTransition: Transition = { + duration: 0.3, + ease: "easeInOut" +}; + +const loginButtonVariants: Variants = { + initial: { + opacity: 0, + y: 10 + }, + animate: { + opacity: 1, + y: 0 + } +}; + +const loginButtonTransition: Transition = { + duration: 0.3, + delay: 0.4, + ease: "easeInOut" +}; + +interface LoginFormProps { + onSwitchMode: () => void; +} + +export function LoginForm({ onSwitchMode }: LoginFormProps) { + const [isLoading, setIsLoading] = useState(false); + 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); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (isLoading) return; + + const username = usernameElement.current!.value.trim(); + const password = passwordElement.current!.value.trim(); + + if (!username || !password) { + showAlert("danger", "Пожалуйста, заполните все поля"); + return; + } + + setIsLoading(true); + + try { + const derived = await deriveAuthSecret(username, password); + const request: LoginRequest = { + username: username, + password: derived + } + + 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(); + setUser(data.token, data.user); + + try { + await ensureKeysOnLogin(password, data.token); + } catch (e) { + console.error("Key setup failed:", e); + } + + navigate("/chat"); + + try { + if (isSupported()) { + const initialized = await initialize(); + if (initialized) { + await subscribe(data.token); + + 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(); + + if (response.status === 403 && response.headers.get("suspension_reason")) { + const suspensionReason = response.headers.get("suspension_reason"); + const setSuspended = useAppState.getState().setSuspended; + setSuspended(suspensionReason || "No reason provided"); + return; + } + + showAlert("danger", data.message || "Неверное имя пользователя или пароль"); + } + } catch (error) { + showAlert("danger", "Ошибка соединения с сервером"); + } finally { + setIsLoading(false); + } + } + + return ( + <> + +
+ + + + + + + + + + +
+ + + {isLoading ? "Вход..." : "Войти"} + + +
+
+ +

+ Ещё нет аккаунта? + { + e.preventDefault(); + onSwitchMode(); + }}> + Зарегистрируйтесь + +

+
+ + ); +} + diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx deleted file mode 100644 index 15e8c60..0000000 --- a/frontend/src/pages/auth/LoginPage.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useImmer } from "use-immer"; -import { AlertsContainer, type Alert, type AlertType } from "./Auth"; -import { AuthContainer, AuthHeader } from "./Auth"; -import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; -import { API_BASE_URL } from "@/core/config"; -import { useRef } from "react"; -import type { TextField } from "mdui/components/text-field"; -import { useAppState } from "@/pages/chat/state"; -import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; -import { isElectron } from "@/core/electron/electron"; -import { useNavigate } from "react-router-dom"; -import styles from "./auth.module.scss"; -import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; -import { MaterialButton, MaterialTextField } from "@/utils/material"; - -export default function LoginPage() { - const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); - const navigate = useNavigate(); - const { navigate: navigateDownloadApp } = useDownloadAppScreen(); - if (navigateDownloadApp) return navigateDownloadApp; - - 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 derived = await deriveAuthSecret(username, password); - const request: LoginRequest = { - username: username, - password: derived - } - - 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(); - - // Check for suspension - if (response.status === 403 && response.headers.get("suspension_reason")) { - const suspensionReason = response.headers.get("suspension_reason"); - const setSuspended = useAppState.getState().setSuspended; - setSuspended(suspensionReason || "No reason provided"); - return; // Don't show alert, SuspensionDialog will be shown - } - - showAlert("danger", data.message || "Неверное имя пользователя или пароль"); - } - } catch (error) { - showAlert("danger", "Ошибка соединения с сервером"); - } - }}> - - - - - - Войти - - - -
-
- ) -} diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx new file mode 100644 index 0000000..b3e5c02 --- /dev/null +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -0,0 +1,249 @@ +import { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { motion, type Transition, type Variants } from "motion/react"; +import { useImmer } from "use-immer"; +import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types"; +import { API_BASE_URL } from "@/core/config"; +import { useAppState } from "@/pages/chat/state"; +import { MaterialButton, MaterialIconButton } from "@/utils/material"; +import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; +import type { Alert, AlertType } from "./Auth"; +import { AuthHeader, AlertsContainer } from "./Auth"; +import styles from "./auth.module.scss"; + +const registerFieldVariants: Variants = { + initial: { + opacity: 0, + y: 10 + }, + animate: { + opacity: 1, + y: 0 + } +}; + +const registerFieldTransition: Transition = { + duration: 0.3, + ease: "easeInOut" +}; + +const registerButtonVariants: Variants = { + initial: { + opacity: 0, + y: 10 + }, + animate: { + opacity: 1, + y: 0 + } +}; + +const registerButtonTransition: Transition = { + duration: 0.3, + delay: 0.6, + ease: "easeInOut" +}; + +interface RegisterFormProps { + onSwitchMode: () => void; +} + +export function RegisterForm({ onSwitchMode }: RegisterFormProps) { + const [isLoading, setIsLoading] = useState(false); + 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 displayNameElement = useRef(null); + const usernameElement = useRef(null); + const passwordElement = useRef(null); + const confirmPasswordElement = useRef(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (isLoading) return; + + const displayName = displayNameElement.current!.value.trim(); + const username = usernameElement.current!.value.trim(); + const password = passwordElement.current!.value.trim(); + const confirmPassword = confirmPasswordElement.current!.value.trim(); + + if (!displayName || !username || !password || !confirmPassword) { + showAlert("danger", "Пожалуйста, заполните все поля"); + return; + } + + if (password !== confirmPassword) { + showAlert("danger", "Пароли не совпадают"); + return; + } + + if (displayName.length < 1 || displayName.length > 64) { + showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов"); + return; + } + + if (username.length < 3 || username.length > 20) { + showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов"); + return; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания"); + return; + } + + if (password.length < 5 || password.length > 50) { + showAlert("danger", "Пароль должен быть от 5 до 50 символов"); + return; + } + + setIsLoading(true); + + try { + const derived = await deriveAuthSecret(username, password); + const request: RegisterRequest = { + display_name: displayName, + username: username, + password: derived, + confirm_password: derived + } + + 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(); + setUser(data.token, data.user); + + 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", "Ошибка соединения с сервером"); + } finally { + setIsLoading(false); + } + } + + return ( + <> + +
+ + + + + + + + + + + + + + + +
+ + + + + + {isLoading ? "Регистрация..." : "Зарегистрироваться"} + + +
+ +
+
+ + ); +} + diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx deleted file mode 100644 index 991ec68..0000000 --- a/frontend/src/pages/auth/RegisterPage.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { useImmer } from "use-immer"; -import { AuthContainer, AuthHeader } from "./Auth"; -import { AlertsContainer, type Alert, type AlertType } from "./Auth"; -import { useRef } from "react"; -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 "@/pages/chat/state"; -import { MaterialButton, MaterialTextField } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; -import { useNavigate } from "react-router-dom"; -import styles from "./auth.module.scss"; -import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; - -export default function RegisterPage() { - const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); - const navigate = useNavigate(); - const { navigate: navigateDownloadApp } = useDownloadAppScreen(); - if (navigateDownloadApp) return navigateDownloadApp; - - function showAlert(type: AlertType, message: string) { - updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); - } - - const displayNameElement = useRef(null); - const usernameElement = useRef(null); - const passwordElement = useRef(null); - const confirmPasswordElement = useRef(null); - - return ( - - -
- - -
{ - e.preventDefault(); - - const displayName = displayNameElement.current!.value.trim(); - const username = usernameElement.current!.value.trim(); - const password = passwordElement.current!.value.trim(); - const confirmPassword = confirmPasswordElement.current!.value.trim(); - - if (!displayName || !username || !password || !confirmPassword) { - showAlert("danger", "Пожалуйста, заполните все поля"); - return; - } - - if (password !== confirmPassword) { - showAlert("danger", "Пароли не совпадают"); - return; - } - - if (displayName.length < 1 || displayName.length > 64) { - showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов"); - return; - } - - if (username.length < 3 || username.length > 20) { - showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов"); - return; - } - - // Validate username format (only English letters, numbers, dashes, underscores) - if (!/^[a-zA-Z0-9_-]+$/.test(username)) { - showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания"); - return; - } - - if (password.length < 5 || password.length > 50) { - showAlert("danger", "Пароль должен быть от 5 до 50 символов"); - return; - } - - try { - const derived = await deriveAuthSecret(username, password); - const request: RegisterRequest = { - display_name: displayName, - username: username, - password: derived, - confirm_password: derived - } - - 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/auth/auth.module.scss b/frontend/src/pages/auth/auth.module.scss index ca1560f..846bdbb 100644 --- a/frontend/src/pages/auth/auth.module.scss +++ b/frontend/src/pages/auth/auth.module.scss @@ -1,52 +1,321 @@ +@use "sass:color"; @use "../../css/colors" as *; @use "../../css/material" as *; +@keyframes rotateGradient { + from { + transform: translate(-50%, -50%) rotate(0deg); + } + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +@keyframes slideInDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shake { + 0%, 100% { + transform: translateX(0); + } + 10%, 30%, 50%, 70%, 90% { + transform: translateX(-4px); + } + 20%, 40%, 60%, 80% { + transform: translateX(4px); + } +} + .authContainer { display: flex; justify-content: center; align-items: center; - height: 100%; + min-height: 100vh; + width: 100vw; padding: 2rem; - background-color: $color-dark-surface; + position: fixed; + top: 0; + left: 0; + overflow: hidden; + background: $color-dark-surface; + .gradientBackground { + $size: 550px; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: $size; + height: $size; + background: conic-gradient( + from 0deg, + rgba(147, 51, 234, 0.5) 0%, + rgba(99, 102, 241, 0.6) 12.5%, + rgba(59, 130, 246, 0.55) 25%, + rgba(168, 85, 247, 0.5) 37.5%, + rgba(217, 70, 239, 0.6) 50%, + rgba(236, 72, 153, 0.55) 62.5%, + rgba(192, 132, 252, 0.5) 75%, + rgba(126, 34, 206, 0.6) 87.5%, + rgba(147, 51, 234, 0.5) 100% + ); + animation: rotateGradient 8s linear infinite; + border-radius: 50%; + filter: blur(80px); + z-index: 0; + will-change: transform; + backface-visibility: hidden; + } + .authCard { - background-color: $color-dark-surface-container; + background: rgba($color-dark-surface-container, 0.7); + backdrop-filter: blur(20px); color: $color-dark-on-surface; - border-radius: 12px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + border-radius: 24px; + border: 1px solid rgba($color-dark-outline, 0.1); + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba($color-dark-primary, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.05); width: 100%; max-width: 450px; overflow: hidden; - animation: authCardAnimation 0.3s ease-in-out; - } + position: relative; + z-index: 1; - .authHeader { - margin: 0; - padding: 16px; - padding-bottom: 0; - text-align: center; + .formWrapper { + position: absolute; + width: 100%; + top: 0; + left: 0; - h2 { - font-size: 1.8rem; - margin: 0; - margin-bottom: 0.5rem; - align-items: center; - display: flex; - flex-direction: row; - gap: 10px; - justify-content: center; - } - } - - .authBody { - padding: 25px; - padding-bottom: 16px; - - form { - display: flex; - flex-direction: column; - gap: 10px; + .authHeader { + margin: 0; + padding: 24px; + padding-bottom: 8px; + text-align: center; + + h2 { + font-size: 1.8rem; + margin: 0; + margin-bottom: 0.5rem; + align-items: center; + display: flex; + flex-direction: row; + gap: 10px; + justify-content: center; + font-weight: 600; + + .material-symbols { + color: $color-dark-primary; + filter: drop-shadow(0 0 8px rgba($color-dark-primary, 0.4)); + } + } + + p { + color: $color-dark-on-surface-variant; + font-size: 0.95rem; + margin: 0; + } + } + + .authBody { + padding: 24px; + padding-bottom: 20px; + + form { + display: flex; + flex-direction: column; + gap: 16px; + + .authButtons { + display: flex; + flex-direction: row; + gap: 16px; + } + } + + .registerLink { + text-align: center; + margin-top: 16px; + font-size: 0.9rem; + color: $color-dark-on-surface-variant; + + a { + color: $color-dark-primary; + margin-inline-start: 3px; + } + } + } } } } +// AuthTextField Styles +.authTextField { + position: relative; + width: 100%; + + .fieldContainer { + position: relative; + display: flex; + align-items: center; + gap: 10px; + background: rgba($color-dark-surface-variant, 0.3); + border: 1px solid rgba($color-dark-outline, 0.2); + border-radius: 12px; + padding: 0 0 0 12px; + transition: all 0.3s ease; + min-height: 44px; + + &:hover { + border-color: rgba($color-dark-outline, 0.4); + background: rgba($color-dark-surface-variant, 0.4); + } + + &.focused { + border-color: $color-dark-primary; + background: rgba($color-dark-surface-variant, 0.5); + box-shadow: + 0 0 0 4px rgba($color-dark-primary, 0.1), + 0 4px 12px rgba($color-dark-primary, 0.2); + } + + &.error { + border-color: $color-dark-error; + animation: shake 0.4s ease; + + &.focused { + box-shadow: + 0 0 0 4px rgba($color-dark-error, 0.1), + 0 4px 12px rgba($color-dark-error, 0.2); + } + } + + &.noIcon { + gap: 0; + + .inputWrapper { + margin-left: 0; + } + } + + &.hasToggle { + padding-right: 12px; + } + } + + .fieldIcon { + color: $color-dark-on-surface-variant; + font-size: 18px; + flex-shrink: 0; + transition: color 0.3s ease; + + .fieldContainer.focused & { + color: $color-dark-primary; + } + } + + .inputWrapper { + position: relative; + flex: 1; + display: flex; + align-items: center; + min-height: 44px; + } + + .input { + width: 100%; + background: transparent; + border: none; + outline: none; + color: $color-dark-on-surface; + font-size: 0.95rem; + font-family: inherit; + padding: 12px 0 12px 0; + line-height: 1.4; + height: auto; + min-height: 20px; + + &::placeholder { + color: $color-dark-on-surface-variant; + opacity: 0.7; + } + + &:focus::placeholder { + opacity: 0.5; + } + } + + .togglePassword { + background: none; + border: none; + color: $color-dark-on-surface-variant; + cursor: pointer; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s ease; + flex-shrink: 0; + + &:hover { + background: rgba($color-dark-on-surface, 0.1); + color: $color-dark-on-surface; + } + + &:active { + transform: scale(0.95); + } + + .material-symbols { + font-size: 18px; + } + } + + .counter { + margin-top: 4px; + padding-left: 16px; + font-size: 0.75rem; + color: $color-dark-on-surface-variant; + text-align: right; + } +} + +// Alert Styles +.alertContainer { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + + .alert { + padding: 12px 16px; + border-radius: 12px; + font-size: 0.9rem; + line-height: 1.5; + animation: slideInDown 0.3s ease; + + &.alert-success { + background: rgba($color-dark-primary-container, 0.3); + color: $color-dark-on-primary-container; + border: 1px solid rgba($color-dark-primary, 0.3); + } + + &.alert-danger { + background: rgba($color-dark-error-container, 0.3); + color: $color-dark-on-error-container; + border: 1px solid rgba($color-dark-error, 0.3); + } + } +} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/ChatInput.module.scss b/frontend/src/pages/chat/css/ChatInput.module.scss index 40a5108..cdd1f52 100644 --- a/frontend/src/pages/chat/css/ChatInput.module.scss +++ b/frontend/src/pages/chat/css/ChatInput.module.scss @@ -116,7 +116,7 @@ width: 50px; height: 50px; border-radius: 50%; - background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%)); + background-color: $color-dark-primary; color: $color-dark-on-primary; border: 1px solid rgba($color-dark-primary, 0.5); cursor: pointer; diff --git a/frontend/src/pages/chat/css/Message.module.scss b/frontend/src/pages/chat/css/Message.module.scss index 6b61916..f1cfe50 100644 --- a/frontend/src/pages/chat/css/Message.module.scss +++ b/frontend/src/pages/chat/css/Message.module.scss @@ -210,7 +210,7 @@ left: 0; right: 0; bottom: 0; - background: linear-gradient(135deg, rgba($color-dark-primary, 0.05), rgba($color-dark-tertiary, 0.03)); + background: linear-gradient(135deg, rgba(147, 51, 234, 0.05), rgba(99, 102, 241, 0.03)); pointer-events: none; z-index: 0; } @@ -232,7 +232,7 @@ flex-direction: row-reverse; .messageInner { - background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%)); + background: linear-gradient(135deg, #9333EA, #6366F1, #3B82F6); color: $color-dark-on-primary; border-top-right-radius: 5px; box-shadow: 0 0 20px rgba($color-dark-primary, 0.4); diff --git a/frontend/src/pages/chat/css/callWindow.module.scss b/frontend/src/pages/chat/css/callWindow.module.scss index 473058f..85602a1 100644 --- a/frontend/src/pages/chat/css/callWindow.module.scss +++ b/frontend/src/pages/chat/css/callWindow.module.scss @@ -29,7 +29,6 @@ height: 100vh; background-color: rgba($color-dark-surface, 0.98); backdrop-filter: blur(40px); - -webkit-backdrop-filter: blur(40px); border: none; border-radius: 0; cursor: default; @@ -64,7 +63,6 @@ height: 300px; background-color: rgba($color-dark-surface, 0.95); backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); border: 2px solid rgba($color-dark-outline, 0.4); border-radius: 16px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); @@ -415,7 +413,6 @@ font-weight: 600; border-radius: 8px; backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); } &.localVideo { @@ -455,7 +452,6 @@ color: $color-dark-on-primary; border-radius: 8px; backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); pointer-events: none; z-index: 1; } diff --git a/frontend/src/pages/chat/css/layout.module.scss b/frontend/src/pages/chat/css/layout.module.scss index b07ecfb..165e9b3 100644 --- a/frontend/src/pages/chat/css/layout.module.scss +++ b/frontend/src/pages/chat/css/layout.module.scss @@ -4,13 +4,14 @@ .chatInterface { height: 100%; - background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%); + background: $color-dark-background; + // background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%); position: relative; overflow: hidden; &::before { content: ''; - position: fixed; + position: absolute; top: 0; left: 0; right: 0; @@ -20,7 +21,7 @@ radial-gradient(circle at 80% 20%, rgba($color-dark-tertiary, 0.15) 0%, transparent 50%), radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.1) 0%, transparent 50%); pointer-events: none; - z-index: 0; + z-index: 10; } .allContainer { diff --git a/frontend/src/pages/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss index d0e998d..098c72e 100644 --- a/frontend/src/pages/chat/css/left-panel.module.scss +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -30,11 +30,11 @@ flex-grow: 1; font-size: 1.8rem; font-weight: 700; - background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #C084FC, #7E22CE); + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; - text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); + text-shadow: 0 0 20px rgba(147, 51, 234, 0.5); } .profile { diff --git a/frontend/src/pages/chat/css/profile-dialog.module.scss b/frontend/src/pages/chat/css/profile-dialog.module.scss index e10b4c9..3a1d9a8 100644 --- a/frontend/src/pages/chat/css/profile-dialog.module.scss +++ b/frontend/src/pages/chat/css/profile-dialog.module.scss @@ -53,6 +53,9 @@ .usernameWithBadge { gap: 0; + display: flex; + flex-direction: row; + align-items: center; .usernameInput { background: none; diff --git a/frontend/src/pages/home/home.module.scss b/frontend/src/pages/home/home.module.scss index 40327db..7284c6a 100644 --- a/frontend/src/pages/home/home.module.scss +++ b/frontend/src/pages/home/home.module.scss @@ -58,7 +58,7 @@ font-weight: 700; margin: 0; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); @@ -99,7 +99,7 @@ line-height: 1.1; margin-bottom: 1.5rem; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary, $color-dark-secondary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 30px rgba($color-dark-primary, 0.5); @@ -246,7 +246,7 @@ font-weight: 700; margin-bottom: 3rem; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); @@ -348,7 +348,7 @@ font-weight: 700; margin-bottom: 1.5rem; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); @@ -387,7 +387,7 @@ font-weight: 700; margin-bottom: 1.5rem; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); diff --git a/frontend/src/pages/not-found/not-found.module.scss b/frontend/src/pages/not-found/not-found.module.scss index 82cefbb..69a33c9 100644 --- a/frontend/src/pages/not-found/not-found.module.scss +++ b/frontend/src/pages/not-found/not-found.module.scss @@ -3,7 +3,7 @@ align-items: center; justify-content: center; min-height: 100vh; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #9333EA 0%, #6366F1 100%); padding: 2rem; } @@ -42,7 +42,7 @@ .errorCode { font-size: 6rem; font-weight: 900; - color: #667eea; + color: #9333EA; line-height: 1; margin-bottom: 1rem; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); @@ -58,7 +58,7 @@ display: flex; align-items: center; justify-content: center; - color: #667eea; + color: #9333EA; opacity: 0.7; } diff --git a/frontend/src/utils/material.tsx b/frontend/src/utils/material.tsx index bd698fc..31203d8 100644 --- a/frontend/src/utils/material.tsx +++ b/frontend/src/utils/material.tsx @@ -40,7 +40,7 @@ import type { Badge } from 'mdui/components/badge'; import type { CircularProgress } from 'mdui/components/circular-progress'; import type { BottomAppBar } from 'mdui/components/bottom-app-bar'; -setColorScheme("#91cef4"); +setColorScheme("#9333EA"); type BasePropCustomization = Override, { ref?: Ref;