Redesign the UI

This commit is contained in:
2025-11-07 11:44:27 +03:00
Unverified
parent 4f79e03fd0
commit 59472ea1d7
21 changed files with 1306 additions and 522 deletions
+3
View File
@@ -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
View File
@@ -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
-75
View File
@@ -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;
+44 -43
View File
@@ -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%);
+107 -10
View File
@@ -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>
) )
} }
+164
View File
@@ -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>
)
}
+138
View File
@@ -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";
+220
View File
@@ -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>
</>
);
}
-155
View File
@@ -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>
)
}
+249
View File
@@ -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>
</>
);
}
-173
View File
@@ -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>
)
}
+299 -30
View File
@@ -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;
+5 -5
View File
@@ -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;
} }
+1 -1
View File
@@ -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>;