mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Redesign the UI
This commit is contained in:
@@ -56,3 +56,6 @@ When working with this project, follow these rules:
|
|||||||
- Use SCSS modules
|
- Use SCSS modules
|
||||||
- Use nested styles
|
- Use nested styles
|
||||||
- Put SCSS into one folder per page
|
- Put SCSS into one folder per page
|
||||||
|
|
||||||
|
## Animations with Framer Motion
|
||||||
|
- Don't use variants if they are used only once
|
||||||
+57
-11
@@ -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 { ElectronTitleBar } from "./Electron";
|
||||||
import { useAppState } from "./pages/chat/state";
|
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 { parseProfileLink } from "./core/profileLinks";
|
||||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||||
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
|
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
|
||||||
|
import { delay } from "./utils/utils";
|
||||||
|
|
||||||
// Lazy load route components
|
// Lazy load route components
|
||||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||||
const LoginPage = lazy(() => import("./pages/auth/LoginPage"));
|
const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
|
||||||
const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
|
|
||||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||||
|
|
||||||
const routeConfig: RouteObject[] = [
|
const routeConfig: RouteObject[] = [
|
||||||
{ path: "/", element: <HomePage /> },
|
{ path: "/", element: <HomePage /> },
|
||||||
{ path: "/login", element: <LoginPage /> },
|
{ path: "/auth", element: <AuthPage /> },
|
||||||
{ path: "/register", element: <RegisterPage /> },
|
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
|
||||||
|
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
|
||||||
{ path: "/download-app", element: <DownloadAppPage /> },
|
{ path: "/download-app", element: <DownloadAppPage /> },
|
||||||
{
|
{
|
||||||
path: "/chat",
|
path: "/chat",
|
||||||
@@ -66,6 +68,54 @@ function SmartCatchAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AnimatedRoutes() {
|
||||||
|
const location = useLocation();
|
||||||
|
const prevPathnameRef = useRef(location.pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="sync" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={location.pathname}
|
||||||
|
onAnimationStart={() => {
|
||||||
|
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
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Routes location={location}>
|
||||||
|
{routeConfig.map((route, index) => (
|
||||||
|
<Route key={index} path={route.path} element={route.element} />
|
||||||
|
))}
|
||||||
|
</Routes>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { restoreUserFromStorage, user } = useAppState();
|
const { restoreUserFromStorage, user } = useAppState();
|
||||||
const [authReady, setAuthReady] = useState(false);
|
const [authReady, setAuthReady] = useState(false);
|
||||||
@@ -80,11 +130,7 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ElectronTitleBar />
|
<ElectronTitleBar />
|
||||||
<div id="main-wrapper">
|
<div id="main-wrapper">
|
||||||
<Routes>
|
<AnimatedRoutes />
|
||||||
{routeConfig.map((route, index) => (
|
|
||||||
<Route key={index} path={route.path} element={route.element} />
|
|
||||||
))}
|
|
||||||
</Routes>
|
|
||||||
</div>
|
</div>
|
||||||
{user.isSuspended && (
|
{user.isSuspended && (
|
||||||
<SuspensionDialog
|
<SuspensionDialog
|
||||||
|
|||||||
@@ -1,26 +1,6 @@
|
|||||||
@use "material" as *;
|
@use "material" as *;
|
||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
&.alert-success {
|
|
||||||
background-color: #C6F6D5;
|
|
||||||
color: #22543D;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.alert-danger {
|
|
||||||
background-color: #FED7D7;
|
|
||||||
color: #742A2A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
color: $color-dark-primary;
|
color: $color-dark-primary;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -30,27 +10,6 @@ button, input {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dialog content styles
|
|
||||||
.dialog-content {
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
mdui-text-field {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-area {
|
.rich-text-area {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -83,33 +42,6 @@ button, input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verified badge styles
|
|
||||||
.verified-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
color: $color-dark-primary;
|
|
||||||
vertical-align: middle;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&.small {
|
|
||||||
font-size: 14px;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.medium {
|
|
||||||
font-size: 18px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.large {
|
|
||||||
font-size: 24px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status badge styles (unified for verified and warning)
|
// Status badge styles (unified for verified and warning)
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -143,13 +75,6 @@ button, input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile dialog specific styles
|
|
||||||
.username-with-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similarity-warning {
|
.similarity-warning {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,55 +1,56 @@
|
|||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
|
|
||||||
// Dark
|
// Dark
|
||||||
$color-dark-primary: rgb(145 206 244);
|
// Generated from base color #9333EA (rgb(147, 51, 234))
|
||||||
$color-dark-surface-tint: rgb(145 206 244);
|
$color-dark-primary: rgb(219 185 249);
|
||||||
$color-dark-on-primary: rgb(0 52 74);
|
$color-dark-surface-tint: rgb(219 185 249);
|
||||||
$color-dark-primary-container: rgb(0 76 106);
|
$color-dark-on-primary: rgb(62 36 88);
|
||||||
$color-dark-on-primary-container: rgb(197 231 255);
|
$color-dark-primary-container: rgb(86 59 113);
|
||||||
$color-dark-secondary: rgb(182 201 216);
|
$color-dark-on-primary-container: rgb(240 219 255);
|
||||||
$color-dark-on-secondary: rgb(32 51 62);
|
$color-dark-secondary: rgb(208 193 218);
|
||||||
$color-dark-secondary-container: rgb(55 73 85);
|
$color-dark-on-secondary: rgb(54 44 63);
|
||||||
$color-dark-on-secondary-container: rgb(210 229 244);
|
$color-dark-secondary-container: rgb(77 67 86);
|
||||||
$color-dark-tertiary: rgb(203 193 233);
|
$color-dark-on-secondary-container: rgb(237 221 246);
|
||||||
$color-dark-on-tertiary: rgb(51 44 76);
|
$color-dark-tertiary: rgb(243 183 190);
|
||||||
$color-dark-tertiary-container: rgb(73 66 99);
|
$color-dark-on-tertiary: rgb(75 37 43);
|
||||||
$color-dark-on-tertiary-container: rgb(231 222 255);
|
$color-dark-tertiary-container: rgb(101 58 64);
|
||||||
|
$color-dark-on-tertiary-container: rgb(255 217 221);
|
||||||
$color-dark-error: rgb(255 180 171);
|
$color-dark-error: rgb(255 180 171);
|
||||||
$color-dark-on-error: rgb(105 0 5);
|
$color-dark-on-error: rgb(105 0 5);
|
||||||
$color-dark-error-container: rgb(147 0 10);
|
$color-dark-error-container: rgb(147 0 10);
|
||||||
$color-dark-on-error-container: rgb(255 218 214);
|
$color-dark-on-error-container: rgb(255 218 214);
|
||||||
$color-dark-background: rgb(15 20 23);
|
$color-dark-background: rgb(21 18 24);
|
||||||
$color-dark-on-background: rgb(223 227 231);
|
$color-dark-on-background: rgb(232 224 232);
|
||||||
$color-dark-surface: rgb(15 20 23);
|
$color-dark-surface: rgb(21 18 24);
|
||||||
$color-dark-on-surface: rgb(223 227 231);
|
$color-dark-on-surface: rgb(232 224 232);
|
||||||
$color-dark-surface-variant: rgb(65 72 77);
|
$color-dark-surface-variant: rgb(74 69 78);
|
||||||
$color-dark-on-surface-variant: rgb(193 199 206);
|
$color-dark-on-surface-variant: rgb(204 196 206);
|
||||||
$color-dark-outline: rgb(139 146 151);
|
$color-dark-outline: rgb(150 142 152);
|
||||||
$color-dark-outline-variant: rgb(65 72 77);
|
$color-dark-outline-variant: rgb(74 69 78);
|
||||||
$color-dark-shadow: rgb(0 0 0);
|
$color-dark-shadow: rgb(0 0 0);
|
||||||
$color-dark-scrim: rgb(0 0 0);
|
$color-dark-scrim: rgb(0 0 0);
|
||||||
$color-dark-inverse-surface: rgb(223 227 231);
|
$color-dark-inverse-surface: rgb(232 224 232);
|
||||||
$color-dark-inverse-on-surface: rgb(44 49 52);
|
$color-dark-inverse-on-surface: rgb(51 47 53);
|
||||||
$color-dark-inverse-primary: rgb(31 101 134);
|
$color-dark-inverse-primary: rgb(111 82 138);
|
||||||
$color-dark-primary-fixed: rgb(197 231 255);
|
$color-dark-primary-fixed: rgb(240 219 255);
|
||||||
$color-dark-on-primary-fixed: rgb(0 30 45);
|
$color-dark-on-primary-fixed: rgb(40 13 66);
|
||||||
$color-dark-primary-fixed-dim: rgb(145 206 244);
|
$color-dark-primary-fixed-dim: rgb(219 185 249);
|
||||||
$color-dark-on-primary-fixed-variant: rgb(0 76 106);
|
$color-dark-on-primary-fixed-variant: rgb(86 59 113);
|
||||||
$color-dark-secondary-fixed: rgb(210 229 244);
|
$color-dark-secondary-fixed: rgb(237 221 246);
|
||||||
$color-dark-on-secondary-fixed: rgb(10 30 40);
|
$color-dark-on-secondary-fixed: rgb(33 24 41);
|
||||||
$color-dark-secondary-fixed-dim: rgb(182 201 216);
|
$color-dark-secondary-fixed-dim: rgb(208 193 218);
|
||||||
$color-dark-on-secondary-fixed-variant: rgb(55 73 85);
|
$color-dark-on-secondary-fixed-variant: rgb(77 67 86);
|
||||||
$color-dark-tertiary-fixed: rgb(231 222 255);
|
$color-dark-tertiary-fixed: rgb(255 217 221);
|
||||||
$color-dark-on-tertiary-fixed: rgb(29 23 53);
|
$color-dark-on-tertiary-fixed: rgb(50 16 22);
|
||||||
$color-dark-tertiary-fixed-dim: rgb(203 193 233);
|
$color-dark-tertiary-fixed-dim: rgb(243 183 190);
|
||||||
$color-dark-on-tertiary-fixed-variant: rgb(73 66 99);
|
$color-dark-on-tertiary-fixed-variant: rgb(101 58 64);
|
||||||
$color-dark-surface-dim: rgb(15 20 23);
|
$color-dark-surface-dim: rgb(21 18 24);
|
||||||
$color-dark-surface-bright: rgb(53 58 61);
|
$color-dark-surface-bright: rgb(60 56 62);
|
||||||
$color-dark-surface-container-lowest: rgb(10 15 18);
|
$color-dark-surface-container-lowest: rgb(16 13 18);
|
||||||
$color-dark-surface-container-low: rgb(24 28 31);
|
$color-dark-surface-container-low: rgb(30 26 32);
|
||||||
$color-dark-surface-container: rgb(28 32 36);
|
$color-dark-surface-container: rgb(34 30 36);
|
||||||
$color-dark-surface-container-high: rgb(38 43 46);
|
$color-dark-surface-container-high: rgb(44 41 46);
|
||||||
$color-dark-surface-container-highest: rgb(49 53 57);
|
$color-dark-surface-container-highest: rgb(55 51 57);
|
||||||
$color-dark-surface-primary-container-lightened: color.adjust($color-dark-primary-container, $lightness: 5%);
|
$color-dark-surface-primary-container-lightened: color.adjust($color-dark-primary-container, $lightness: 5%);
|
||||||
$color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%);
|
$color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import styles from "./auth.module.scss";
|
import styles from "./auth.module.scss";
|
||||||
|
|
||||||
export function AuthContainer({ children }: { children?: React.ReactNode }) {
|
export function AuthContainer({ children }: { children?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.authContainer}>
|
<div className={styles.authContainer}>
|
||||||
<div className={styles.authCard}>
|
<div className={styles.gradientBackground} />
|
||||||
|
<motion.div
|
||||||
|
className={styles.authCard}
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.95,
|
||||||
|
y: 10
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
y: 0
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -29,13 +47,63 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
|
|||||||
const iconName = typeof icon == "string" ? icon : icon.name;
|
const iconName = typeof icon == "string" ? icon : icon.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.authHeader}>
|
<motion.div
|
||||||
|
className={styles.authHeader}
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
y: -10
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
delay: 0.1,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h2>
|
<h2>
|
||||||
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
|
<motion.span
|
||||||
|
className={`material-symbols ${iconType} large`}
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.8,
|
||||||
|
rotate: -10
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
rotate: 0
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.5,
|
||||||
|
delay: 0.2,
|
||||||
|
ease: "easeOut"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{iconName}
|
||||||
|
</motion.span>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<p>{subtitle}</p>
|
<motion.p
|
||||||
</div>
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
y: 10
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
delay: 0.3,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +115,40 @@ export interface Alert {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
|
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
|
||||||
|
const displayAlerts = alerts.slice(-3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.alertContainer}>
|
||||||
{alerts.slice(-3).map((alert, i) => {
|
<AnimatePresence mode="popLayout">
|
||||||
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
|
{displayAlerts.map((alert, i) => (
|
||||||
})}
|
<motion.div
|
||||||
|
key={`${i}-${alert.message}`}
|
||||||
|
className={`${styles.alert} alert-${alert.type}`}
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
y: -20,
|
||||||
|
scale: 0.95
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
y: -10,
|
||||||
|
scale: 0.95
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
layout
|
||||||
|
>
|
||||||
|
{alert.message}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
const loginFormRef = useRef<HTMLDivElement>(null);
|
||||||
|
const registerFormRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerHeight, setContainerHeight] = useState<number | "auto">("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<HTMLDivElement | null>,
|
||||||
|
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 (
|
||||||
|
<AuthContainer>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
|
||||||
|
transition: "height 0.3s ease"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="sync" custom={direction}>
|
||||||
|
{currentMode === "login" ? (
|
||||||
|
<motion.div
|
||||||
|
key="login"
|
||||||
|
ref={loginFormRef}
|
||||||
|
custom={direction}
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={slideTransition}
|
||||||
|
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
|
||||||
|
className={styles.formWrapper}
|
||||||
|
>
|
||||||
|
<LoginForm onSwitchMode={() => switchMode("register")} />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="register"
|
||||||
|
ref={registerFormRef}
|
||||||
|
custom={direction}
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={slideTransition}
|
||||||
|
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
|
||||||
|
className={styles.formWrapper}
|
||||||
|
>
|
||||||
|
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</AuthContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<AuthTextFieldHandle, AuthTextFieldProps>(
|
||||||
|
({
|
||||||
|
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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalValue(newValue);
|
||||||
|
}
|
||||||
|
onChange?.(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasError = false; // Can be extended for validation
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`${styles.authTextField} ${className}`}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
whileFocus={{ scale: 1.01 }}
|
||||||
|
>
|
||||||
|
<div className={`${styles.fieldContainer} ${isFocused ? styles.focused : ""} ${hasError ? styles.error : ""} ${!icon ? styles.noIcon : ""} ${togglePassword && type === "password" ? styles.hasToggle : ""}`}>
|
||||||
|
{icon && (
|
||||||
|
<span className={`material-symbols filled ${styles.fieldIcon}`}>
|
||||||
|
{icon.replace("--filled", "").replace("--outlined", "")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type={displayType}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
autoComplete={autocomplete}
|
||||||
|
required={required}
|
||||||
|
maxLength={maxlength}
|
||||||
|
placeholder={label + (required ? " *" : "")}
|
||||||
|
className={styles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{togglePassword && type === "password" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.togglePassword}
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<span className="material-symbols filled">
|
||||||
|
{showPassword ? "visibility_off" : "visibility"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{counter && maxlength && (
|
||||||
|
<div className={styles.counter}>
|
||||||
|
{charCount} / {maxlength}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
AuthTextField.displayName = "AuthTextField";
|
||||||
@@ -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<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<AuthTextFieldHandle>(null);
|
||||||
|
const passwordElement = useRef<AuthTextFieldHandle>(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 (
|
||||||
|
<>
|
||||||
|
<AuthHeader
|
||||||
|
icon="login"
|
||||||
|
title="Добро пожаловать!"
|
||||||
|
subtitle="Войдите в свой аккаунт"
|
||||||
|
/>
|
||||||
|
<div className={styles.authBody}>
|
||||||
|
<AlertsContainer alerts={alerts} />
|
||||||
|
<motion.form onSubmit={handleSubmit}>
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
variants={loginFieldVariants}
|
||||||
|
transition={loginFieldTransition}
|
||||||
|
>
|
||||||
|
<AuthTextField
|
||||||
|
label="@Имя пользователя"
|
||||||
|
name="username"
|
||||||
|
icon="person--filled"
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
ref={usernameElement} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
variants={loginFieldVariants}
|
||||||
|
transition={loginFieldTransition}
|
||||||
|
>
|
||||||
|
<AuthTextField
|
||||||
|
label="Пароль"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
toggle-password
|
||||||
|
icon="password--filled"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
ref={passwordElement} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className={styles.authButtons}>
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
variants={loginButtonVariants}
|
||||||
|
transition={loginButtonTransition}
|
||||||
|
>
|
||||||
|
<MaterialButton type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "Вход..." : "Войти"}
|
||||||
|
</MaterialButton>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.form>
|
||||||
|
|
||||||
|
<p className={styles.registerLink}>
|
||||||
|
Ещё нет аккаунта?
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSwitchMode();
|
||||||
|
}}>
|
||||||
|
Зарегистрируйтесь
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<Alert[]>([]);
|
|
||||||
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<TextField>(null);
|
|
||||||
const passwordElement = useRef<TextField>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContainer>
|
|
||||||
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
|
|
||||||
<div className={styles.authBody}>
|
|
||||||
<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 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", "Ошибка соединения с сервером");
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
|
|
||||||
<MaterialTextField
|
|
||||||
label="@Имя пользователя"
|
|
||||||
name="username"
|
|
||||||
variant="outlined"
|
|
||||||
icon="person--filled"
|
|
||||||
autocomplete="username"
|
|
||||||
required
|
|
||||||
ref={usernameElement} />
|
|
||||||
|
|
||||||
<MaterialTextField
|
|
||||||
label="Пароль"
|
|
||||||
name="password"
|
|
||||||
variant="outlined"
|
|
||||||
type="password"
|
|
||||||
toggle-password
|
|
||||||
icon="password--filled"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
ref={passwordElement} />
|
|
||||||
|
|
||||||
<MaterialButton type="submit">Войти</MaterialButton>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<p>
|
|
||||||
Ещё нет аккаунта?
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="link"
|
|
||||||
onClick={() => navigate("/register")}>
|
|
||||||
Зарегистрируйтесь
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AuthContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<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 displayNameElement = useRef<AuthTextFieldHandle>(null);
|
||||||
|
const usernameElement = useRef<AuthTextFieldHandle>(null);
|
||||||
|
const passwordElement = useRef<AuthTextFieldHandle>(null);
|
||||||
|
const confirmPasswordElement = useRef<AuthTextFieldHandle>(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 (
|
||||||
|
<>
|
||||||
|
<AuthHeader
|
||||||
|
icon="person_add"
|
||||||
|
title="Регистрация"
|
||||||
|
subtitle="Создайте новый аккаунт"
|
||||||
|
/>
|
||||||
|
<div className={styles.authBody}>
|
||||||
|
<AlertsContainer alerts={alerts} />
|
||||||
|
<motion.form onSubmit={handleSubmit}>
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
variants={registerFieldVariants}
|
||||||
|
transition={registerFieldTransition}
|
||||||
|
>
|
||||||
|
<AuthTextField
|
||||||
|
label="Отображаемое имя"
|
||||||
|
name="display_name"
|
||||||
|
icon="badge--filled"
|
||||||
|
autocomplete="name"
|
||||||
|
maxlength={64}
|
||||||
|
counter
|
||||||
|
required
|
||||||
|
ref={displayNameElement} />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
variants={registerFieldVariants}
|
||||||
|
transition={registerFieldTransition}
|
||||||
|
>
|
||||||
|
<AuthTextField
|
||||||
|
label="@Имя пользователя"
|
||||||
|
name="username"
|
||||||
|
icon="person--filled"
|
||||||
|
autocomplete="username"
|
||||||
|
maxlength={20}
|
||||||
|
counter
|
||||||
|
required
|
||||||
|
ref={usernameElement} />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
variants={registerFieldVariants}
|
||||||
|
transition={registerFieldTransition}
|
||||||
|
>
|
||||||
|
<AuthTextField
|
||||||
|
label="Пароль"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
toggle-password
|
||||||
|
icon="password--filled"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
ref={passwordElement} />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
variants={registerFieldVariants}
|
||||||
|
transition={registerFieldTransition}
|
||||||
|
>
|
||||||
|
<AuthTextField
|
||||||
|
label="Подтвердите пароль"
|
||||||
|
name="confirm_password"
|
||||||
|
type="password"
|
||||||
|
toggle-password
|
||||||
|
icon="password--filled"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
ref={confirmPasswordElement} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className={styles.authButtons}>
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
variants={registerButtonVariants}
|
||||||
|
transition={registerButtonTransition}
|
||||||
|
>
|
||||||
|
<MaterialIconButton icon="arrow_back" onClick={onSwitchMode} />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
variants={registerButtonVariants}
|
||||||
|
transition={registerButtonTransition}
|
||||||
|
>
|
||||||
|
<MaterialButton type="submit" disabled={isLoading} loading={isLoading} icon="person_add">
|
||||||
|
{isLoading ? "Регистрация..." : "Зарегистрироваться"}
|
||||||
|
</MaterialButton>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</motion.form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<Alert[]>([]);
|
|
||||||
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<TextField>(null);
|
|
||||||
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={styles.authBody}>
|
|
||||||
<AlertsContainer alerts={alerts} />
|
|
||||||
|
|
||||||
<form onSubmit={async (e) => {
|
|
||||||
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", "Ошибка соединения с сервером");
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<MaterialTextField
|
|
||||||
label="Отображаемое имя"
|
|
||||||
name="display_name"
|
|
||||||
variant="outlined"
|
|
||||||
icon="badge--filled"
|
|
||||||
autocomplete="name"
|
|
||||||
maxlength={64}
|
|
||||||
counter
|
|
||||||
required
|
|
||||||
ref={displayNameElement} />
|
|
||||||
<MaterialTextField
|
|
||||||
label="@Имя пользователя"
|
|
||||||
name="username"
|
|
||||||
variant="outlined"
|
|
||||||
icon="person--filled"
|
|
||||||
autocomplete="username"
|
|
||||||
maxlength={20}
|
|
||||||
counter
|
|
||||||
required
|
|
||||||
ref={usernameElement} />
|
|
||||||
<MaterialTextField
|
|
||||||
label="Пароль"
|
|
||||||
name="password"
|
|
||||||
variant="outlined"
|
|
||||||
type="password"
|
|
||||||
toggle-password
|
|
||||||
icon="password--filled"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
ref={passwordElement} />
|
|
||||||
<MaterialTextField
|
|
||||||
label="Подтвердите пароль"
|
|
||||||
name="confirm_password"
|
|
||||||
variant="outlined"
|
|
||||||
type="password"
|
|
||||||
toggle-password
|
|
||||||
icon="password--filled"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
ref={confirmPasswordElement} />
|
|
||||||
|
|
||||||
<MaterialButton type="submit">Зарегистрироваться</MaterialButton>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<p>
|
|
||||||
Уже есть аккаунт?
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
id="login-link"
|
|
||||||
className="link"
|
|
||||||
onClick={() => navigate("/login")}>
|
|
||||||
Войдите
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AuthContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +1,321 @@
|
|||||||
|
@use "sass:color";
|
||||||
@use "../../css/colors" as *;
|
@use "../../css/colors" as *;
|
||||||
@use "../../css/material" 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 {
|
.authContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
min-height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
padding: 2rem;
|
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 {
|
.authCard {
|
||||||
background-color: $color-dark-surface-container;
|
background: rgba($color-dark-surface-container, 0.7);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
color: $color-dark-on-surface;
|
color: $color-dark-on-surface;
|
||||||
border-radius: 12px;
|
border-radius: 24px;
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
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%;
|
width: 100%;
|
||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: authCardAnimation 0.3s ease-in-out;
|
position: relative;
|
||||||
}
|
z-index: 1;
|
||||||
|
|
||||||
.authHeader {
|
.formWrapper {
|
||||||
margin: 0;
|
position: absolute;
|
||||||
padding: 16px;
|
width: 100%;
|
||||||
padding-bottom: 0;
|
top: 0;
|
||||||
text-align: center;
|
left: 0;
|
||||||
|
|
||||||
h2 {
|
.authHeader {
|
||||||
font-size: 1.8rem;
|
margin: 0;
|
||||||
margin: 0;
|
padding: 24px;
|
||||||
margin-bottom: 0.5rem;
|
padding-bottom: 8px;
|
||||||
align-items: center;
|
text-align: center;
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.authBody {
|
h2 {
|
||||||
padding: 25px;
|
font-size: 1.8rem;
|
||||||
padding-bottom: 16px;
|
margin: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
form {
|
.material-symbols {
|
||||||
display: flex;
|
color: $color-dark-primary;
|
||||||
flex-direction: column;
|
filter: drop-shadow(0 0 8px rgba($color-dark-primary, 0.4));
|
||||||
gap: 10px;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
border-radius: 50%;
|
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;
|
color: $color-dark-on-primary;
|
||||||
border: 1px solid rgba($color-dark-primary, 0.5);
|
border: 1px solid rgba($color-dark-primary, 0.5);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -210,7 +210,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 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;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
.messageInner {
|
.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;
|
color: $color-dark-on-primary;
|
||||||
border-top-right-radius: 5px;
|
border-top-right-radius: 5px;
|
||||||
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
|
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: rgba($color-dark-surface, 0.98);
|
background-color: rgba($color-dark-surface, 0.98);
|
||||||
backdrop-filter: blur(40px);
|
backdrop-filter: blur(40px);
|
||||||
-webkit-backdrop-filter: blur(40px);
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -64,7 +63,6 @@
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
background-color: rgba($color-dark-surface, 0.95);
|
background-color: rgba($color-dark-surface, 0.95);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border: 2px solid rgba($color-dark-outline, 0.4);
|
border: 2px solid rgba($color-dark-outline, 0.4);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||||
@@ -415,7 +413,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.localVideo {
|
&.localVideo {
|
||||||
@@ -455,7 +452,6 @@
|
|||||||
color: $color-dark-on-primary;
|
color: $color-dark-on-primary;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
|
|
||||||
.chatInterface {
|
.chatInterface {
|
||||||
height: 100%;
|
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;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 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 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%);
|
radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.1) 0%, transparent 50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.allContainer {
|
.allContainer {
|
||||||
|
|||||||
@@ -30,11 +30,11 @@
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
background: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #C084FC, #7E22CE);
|
||||||
-webkit-background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
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 {
|
.profile {
|
||||||
|
|||||||
@@ -53,6 +53,9 @@
|
|||||||
|
|
||||||
.usernameWithBadge {
|
.usernameWithBadge {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
.usernameInput {
|
.usernameInput {
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
||||||
-webkit-background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
|
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary, $color-dark-secondary);
|
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;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
text-shadow: 0 0 30px rgba($color-dark-primary, 0.5);
|
text-shadow: 0 0 30px rgba($color-dark-primary, 0.5);
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
||||||
-webkit-background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
|
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
||||||
-webkit-background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
|
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
|
||||||
@@ -387,7 +387,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
||||||
-webkit-background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
|
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #9333EA 0%, #6366F1 100%);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
.errorCode {
|
.errorCode {
|
||||||
font-size: 6rem;
|
font-size: 6rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #667eea;
|
color: #9333EA;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #667eea;
|
color: #9333EA;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import type { Badge } from 'mdui/components/badge';
|
|||||||
import type { CircularProgress } from 'mdui/components/circular-progress';
|
import type { CircularProgress } from 'mdui/components/circular-progress';
|
||||||
import type { BottomAppBar } from 'mdui/components/bottom-app-bar';
|
import type { BottomAppBar } from 'mdui/components/bottom-app-bar';
|
||||||
|
|
||||||
setColorScheme("#91cef4");
|
setColorScheme("#9333EA");
|
||||||
|
|
||||||
type BasePropCustomization<Tag extends keyof React.JSX.IntrinsicElements, Type> = Override<ComponentPropsWithoutRef<Tag>, {
|
type BasePropCustomization<Tag extends keyof React.JSX.IntrinsicElements, Type> = Override<ComponentPropsWithoutRef<Tag>, {
|
||||||
ref?: Ref<Type>;
|
ref?: Ref<Type>;
|
||||||
|
|||||||
Reference in New Issue
Block a user