Merge branch 'feature/auth-redesign'

This commit is contained in:
2025-11-18 19:15:19 +03:00
Unverified
41 changed files with 4086 additions and 873 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
+3 -2
View File
@@ -1,8 +1,9 @@
from datetime import datetime
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from utils import * from utils import verify_token
from models import * from models import User, DeviceSession
from db import SessionLocal from db import SessionLocal
security = HTTPBearer() security = HTTPBearer()
+1 -1
View File
@@ -107,7 +107,7 @@ class PushNotificationService:
payload = { payload = {
"title": title, "title": title,
"body": body, "body": body,
"icon": icon or "/logo.png", "icon": icon or "about:blank",
"tag": f"message_{user_id}", "tag": f"message_{user_id}",
"data": data "data": data
} }
+5 -1
View File
@@ -53,7 +53,7 @@ def convert_user(user: User) -> dict:
"verified": user.verified, "verified": user.verified,
"suspended": user.suspended or False, "suspended": user.suspended or False,
"suspension_reason": user.suspension_reason, "suspension_reason": user.suspension_reason,
"deleted": user.deleted or False "deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
} }
@router.get("/check_auth") @router.get("/check_auth")
@@ -313,6 +313,8 @@ def set_public_key(payload: dict, current_user: User = Depends(get_current_user)
pk = payload.get("publicKey") pk = payload.get("publicKey")
if not pk: if not pk:
raise HTTPException(status_code=400, detail="publicKey required") raise HTTPException(status_code=400, detail="publicKey required")
if not isinstance(pk, str) or len(pk) > 10000 or len(pk) < 10:
raise HTTPException(status_code=400, detail="Invalid publicKey format")
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
if row: if row:
row.public_key_b64 = pk row.public_key_b64 = pk
@@ -334,6 +336,8 @@ def set_backup(payload: dict, current_user: User = Depends(get_current_user), db
blob = payload.get("blob") blob = payload.get("blob")
if not blob: if not blob:
raise HTTPException(status_code=400, detail="blob required") raise HTTPException(status_code=400, detail="blob required")
if not isinstance(blob, str) or len(blob) > 1000000: # 1MB limit
raise HTTPException(status_code=400, detail="Invalid blob format or size exceeds 1MB")
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
if row: if row:
row.blob_json = blob row.blob_json = blob
+3
View File
@@ -60,6 +60,9 @@ def revoke_device(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
if not session_id or len(session_id) > 64 or len(session_id) < 1:
raise HTTPException(status_code=400, detail="Invalid session ID")
s = ( s = (
db.query(DeviceSession) db.query(DeviceSession)
.filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id) .filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id)
+35 -11
View File
@@ -164,8 +164,8 @@ def convert_message(msg: Message) -> dict:
"username": reaction.user.display_name "username": reaction.user.display_name
}) })
# Handle deleted users # Handle deleted or suspended users
if msg.author.deleted: if msg.author.deleted or msg.author.suspended:
username = f"Deleted User #{msg.author.id}" username = f"Deleted User #{msg.author.id}"
profile_picture = None profile_picture = None
verified = False verified = False
@@ -198,7 +198,7 @@ def convert_message(msg: Message) -> dict:
} }
def convert_dm_envelope(envelope: DMEnvelope) -> dict: def convert_dm_envelope(db: Session, envelope: DMEnvelope) -> dict:
# Group reactions by emoji # Group reactions by emoji
reactions_dict = {} reactions_dict = {}
if envelope.reactions: if envelope.reactions:
@@ -217,13 +217,10 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
}) })
# Get sender info for verified status # Get sender info for verified status
from models import User
from dependencies import get_db
db = next(get_db())
sender = db.query(User).filter(User.id == envelope.sender_id).first() sender = db.query(User).filter(User.id == envelope.sender_id).first()
# Handle deleted users # Handle deleted or suspended users
if sender and sender.deleted: if sender and (sender.deleted or sender.suspended):
sender_verified = False sender_verified = False
else: else:
sender_verified = sender.verified if sender else False sender_verified = sender.verified if sender else False
@@ -454,9 +451,25 @@ async def dm_send(
if key not in payload: if key not in payload:
raise HTTPException(status_code=400, detail=f"Missing {key}") raise HTTPException(status_code=400, detail=f"Missing {key}")
try:
recipient_id = int(payload["recipientId"])
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid recipientId")
if recipient_id <= 0:
raise HTTPException(status_code=400, detail="Invalid recipientId")
if recipient_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot send DM to yourself")
# Verify recipient exists
recipient = db.query(User).filter(User.id == recipient_id).first()
if not recipient or recipient.deleted or recipient.suspended:
raise HTTPException(status_code=404, detail="Recipient not found")
env = DMEnvelope( env = DMEnvelope(
sender_id=current_user.id, sender_id=current_user.id,
recipient_id=int(payload["recipientId"]), recipient_id=recipient_id,
iv_b64=payload["iv"], iv_b64=payload["iv"],
ciphertext_b64=payload["ciphertext"], ciphertext_b64=payload["ciphertext"],
salt_b64=payload["salt"], salt_b64=payload["salt"],
@@ -590,6 +603,17 @@ async def dm_fetch(request: Request, since: int | None = None, current_user: Use
@router.get("/dm/history/{other_user_id}") @router.get("/dm/history/{other_user_id}")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if other_user_id <= 0:
raise HTTPException(status_code=400, detail="Invalid user ID")
if other_user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot get history with yourself")
# Verify other user exists
other_user = db.query(User).filter(User.id == other_user_id).first()
if not other_user or other_user.deleted or other_user.suspended:
raise HTTPException(status_code=404, detail="User not found")
return convert_envelopes( return convert_envelopes(
db.query(DMEnvelope) db.query(DMEnvelope)
.filter( .filter(
@@ -631,7 +655,7 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge
result.append({ result.append({
"user": convert_user(other_user), "user": convert_user(other_user),
"lastMessage": convert_dm_envelope(latest_message), "lastMessage": convert_dm_envelope(db, latest_message),
"unreadCount": unread_count "unreadCount": unread_count
}) })
@@ -833,7 +857,7 @@ async def add_dm_reaction(
# Refresh envelope to get updated reactions # Refresh envelope to get updated reactions
db.refresh(envelope) db.refresh(envelope)
envelope_data = convert_dm_envelope(envelope) envelope_data = convert_dm_envelope(db, envelope)
# Broadcast reaction update to both participants # Broadcast reaction update to both participants
try: try:
+28 -5
View File
@@ -131,7 +131,7 @@ async def get_user_profile(
verified=current_user.verified, verified=current_user.verified,
suspended=current_user.suspended or False, suspended=current_user.suspended or False,
suspension_reason=current_user.suspension_reason, suspension_reason=current_user.suspension_reason,
deleted=current_user.deleted or False, deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted
) )
@@ -160,7 +160,7 @@ async def list_users(
verified=user.verified, verified=user.verified,
suspended=user.suspended or False, suspended=user.suspended or False,
suspension_reason=user.suspension_reason, suspension_reason=user.suspension_reason,
deleted=user.deleted or False, deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
).model_dump() ).model_dump()
for user in users for user in users
] ]
@@ -275,6 +275,9 @@ async def get_user_by_username(
""" """
Get user profile by username Get user profile by username
""" """
if not username or not is_valid_username(username):
raise HTTPException(status_code=400, detail="Invalid username format")
user = db.query(User).filter(User.username == username).first() user = db.query(User).filter(User.username == username).first()
if not user: if not user:
@@ -282,6 +285,23 @@ async def get_user_by_username(
_ensure_owner_unsuspended(user, db) _ensure_owner_unsuspended(user, db)
# Handle deleted or suspended users
if user.deleted or user.suspended:
return UserProfileResponse(
id=user.id,
username="deleted",
display_name="Deleted User",
profile_picture=None,
bio=None,
online=False,
last_seen=None, # Clear last seen timestamp
created_at=None, # Clear member since timestamp
verified=False,
suspended=False,
suspension_reason=None,
deleted=True
)
return UserProfileResponse( return UserProfileResponse(
id=user.id, id=user.id,
username=user.username, username=user.username,
@@ -294,7 +314,7 @@ async def get_user_by_username(
verified=user.verified, verified=user.verified,
suspended=user.suspended or False, suspended=user.suspended or False,
suspension_reason=user.suspension_reason, suspension_reason=user.suspension_reason,
deleted=user.deleted or False, deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
) )
@router.get("/user/id/{user_id}") @router.get("/user/id/{user_id}")
@@ -305,6 +325,9 @@ async def get_user_by_id(
""" """
Get user profile by user ID Get user profile by user ID
""" """
if user_id <= 0:
raise HTTPException(status_code=400, detail="Invalid user ID")
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
@@ -312,8 +335,8 @@ async def get_user_by_id(
_ensure_owner_unsuspended(user, db) _ensure_owner_unsuspended(user, db)
# Handle deleted users # Handle deleted or suspended users
if user.deleted: if user.deleted or user.suspended:
return UserProfileResponse( return UserProfileResponse(
id=user.id, id=user.id,
username="deleted", username="deleted",
+1 -1
View File
@@ -4,7 +4,7 @@ import jwt
from typing import Optional, Any from typing import Optional, Any
import bcrypt import bcrypt
from constants import * from constants import ACCESS_TOKEN_EXPIRE_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
# JWT Helper Functions # JWT Helper Functions
def create_token(user_id: int, username: str, session_id: str) -> str: def create_token(user_id: int, username: str, session_id: str) -> str:
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading...</title> <title>Loading...</title>
<link rel="icon" href="./src/images/logo.png" /> <link rel="icon" href="./src/images/logo.svg" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+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
+51 -11
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import styles from "./css/searchBar.module.scss"; import styles from "./css/searchBar.module.scss";
import { MaterialIcon } from "@/utils/material"; import { MaterialIcon, type MDUIBottomAppBar } from "@/utils/material";
interface SearchBarProps { interface SearchBarProps {
placeholder: string; placeholder: string;
@@ -11,6 +11,9 @@ interface SearchBarProps {
onToggleExpanded: () => void; onToggleExpanded: () => void;
leftIcon?: string | React.ReactNode; leftIcon?: string | React.ReactNode;
rightIcon?: string | React.ReactNode; rightIcon?: string | React.ReactNode;
containerRef: React.RefObject<HTMLElement | null>;
headerRef?: React.RefObject<HTMLElement | null>;
bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null>;
} }
export default function SearchBar({ export default function SearchBar({
@@ -21,9 +24,14 @@ export default function SearchBar({
isExpanded, isExpanded,
onToggleExpanded, onToggleExpanded,
leftIcon = "search--outlined", leftIcon = "search--outlined",
rightIcon = null rightIcon = null,
containerRef,
headerRef,
bottomAppBarRef
}: SearchBarProps) { }: SearchBarProps) {
const [dynamicHeight, setDynamicHeight] = useState<string>("48px"); const [dynamicHeight, setDynamicHeight] = useState<string>("48px");
const [isTransitioning, setIsTransitioning] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchContainerRef = useRef<HTMLDivElement>(null); const searchContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const parentContainerRef = useRef<HTMLDivElement>(null); const parentContainerRef = useRef<HTMLDivElement>(null);
@@ -33,17 +41,46 @@ export default function SearchBar({
useEffect(() => { useEffect(() => {
if (isExpanded && inputRef.current) { if (isExpanded && inputRef.current) {
inputRef.current.focus(); inputRef.current.focus();
// Set expanded height // Set expanded height, subtracting both header and bottom app bar heights
const leftPanel = document.getElementById('chat-list'); if (containerRef.current) {
if (leftPanel) { const panelHeight = containerRef.current.offsetHeight;
const panelHeight = leftPanel.offsetHeight; let headerHeight = 0;
setDynamicHeight(`${panelHeight}px`); let bottomBarHeight = 0;
// Get header height
if (headerRef?.current) {
headerHeight = headerRef.current.offsetHeight;
}
// Get bottom app bar height
if (bottomAppBarRef?.current) {
bottomBarHeight = bottomAppBarRef.current.offsetHeight;
}
// Calculate height by subtracting both header and bottom bar heights
const availableHeight = panelHeight - headerHeight - bottomBarHeight;
setDynamicHeight(`${availableHeight}px`);
} }
} else { } else {
// Set collapsed height // Set collapsed height
setDynamicHeight("48px"); setDynamicHeight("48px");
} }
}, [isExpanded]);
// Show/hide results and disable overflow during transition
if (isExpanded) {
setShowResults(true);
}
setIsTransitioning(true);
const timeout = setTimeout(() => {
setIsTransitioning(false);
if (!isExpanded) {
setShowResults(false);
}
}, 400); // Match transition duration (0.4s)
return () => clearTimeout(timeout);
}, [isExpanded, containerRef, headerRef, bottomAppBarRef]);
function handleToggle() { function handleToggle() {
onToggleExpanded(); onToggleExpanded();
@@ -107,9 +144,12 @@ export default function SearchBar({
</div> </div>
</div> </div>
{/* Results Section - Only visible when expanded */} {/* Results Section - Visible during expansion and collapse transition */}
{isExpanded && ( {showResults && (
<div className={styles.searchResults}> <div
className={styles.searchResults}
style={{ overflowY: isTransitioning ? "hidden" : "auto" }}
>
{children} {children}
</div> </div>
)} )}
@@ -42,7 +42,7 @@ $font-size: 16px;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; // bottom will be set dynamically by React to account for bottom app bar
border-radius: 0; border-radius: 0;
background-color: $color-dark-surface-container; background-color: $color-dark-surface-container;
// Height will be set dynamically by React // Height will be set dynamically by React
@@ -3,6 +3,7 @@ import { isElectron } from "@/core/electron/electron";
import { websocket } from "@/core/websocket"; import { websocket } from "@/core/websocket";
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
import serviceWorker from "./service-worker?worker&url"; import serviceWorker from "./service-worker?worker&url";
import logo from "@/images/logo.svg";
export interface PushSubscriptionData { export interface PushSubscriptionData {
endpoint: string; endpoint: string;
@@ -111,7 +112,7 @@ async function showMessageNotification(message: any): Promise<void> {
body: message.content.length > 100 body: message.content.length > 100
? message.content.substring(0, 100) + "..." ? message.content.substring(0, 100) + "..."
: message.content, : message.content,
icon: message.profile_picture || "/logo.png", icon: message.profile_picture || logo,
tag: `message_${message.id}`, tag: `message_${message.id}`,
data: { data: {
type: "public_message", type: "public_message",
@@ -1,5 +1,7 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import logo from "@/images/logo.svg";
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
interface NotificationPayload { interface NotificationPayload {
@@ -36,8 +38,8 @@ self.addEventListener("push", function(event: ExtendableEvent) {
const options: NotificationOptions = { const options: NotificationOptions = {
body: data.body, body: data.body,
icon: data.icon || "/logo.png", icon: data.icon || logo,
badge: "/logo.png", badge: logo,
image: data.image, image: data.image,
tag: data.tag || "message", tag: data.tag || "message",
data: data.data, data: data.data,
+164 -41
View File
@@ -44,6 +44,18 @@ let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = n
*/ */
let callSignalingHandler: CallSignalingHandler | null = null; let callSignalingHandler: CallSignalingHandler | null = null;
/**
* Reconnection state
*/
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 30000; // 30 seconds max delay
const INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second
let isReconnecting = false;
let messageHandler: ((e: MessageEvent) => void) | null = null;
let errorHandler: ((e: Event) => void) | null = null;
let closeHandler: ((e: CloseEvent) => void) | null = null;
let openHandler: ((e: Event) => void) | null = null;
/** /**
* Set the global WebSocket message handler * Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages * @param handler - Function to handle WebSocket messages
@@ -60,57 +72,83 @@ export function setCallSignalingHandler(handler: CallSignalingHandler | null): v
callSignalingHandler = handler; callSignalingHandler = handler;
} }
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> { /**
console.log("WebSocket request:", payload); * Clean up all event listeners from the current WebSocket instance
return new Promise((resolve, reject) => { * @private
function requestInner() { */
let listener: ((e: MessageEvent) => void) | null = null; function cleanupWebSocket(): void {
listener = (e) => { if (websocket) {
resolve(JSON.parse(e.data)); if (messageHandler) {
websocket.removeEventListener("message", listener!); websocket.removeEventListener("message", messageHandler);
} }
websocket.addEventListener("message", listener); if (errorHandler) {
websocket.send(JSON.stringify(payload)) websocket.removeEventListener("error", errorHandler);
}
setTimeout(() => reject("Request timed out"), 10000); if (closeHandler) {
websocket.removeEventListener("close", closeHandler);
}
if (openHandler) {
websocket.removeEventListener("open", openHandler);
} }
if (websocket.readyState == 0) { // Close if still connected
websocket.addEventListener("open", requestInner); if (websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING) {
setTimeout(() => reject("Request timed out"), 10000); try {
} else { websocket.close();
requestInner(); } catch (e) {
// Ignore errors during cleanup
}
}
} }
})
} }
/** /**
* This function will wait 3 seconds and them attempts to reconnect the WebSocket. * Calculate exponential backoff delay
* If it fails, tries again in an endless loop until the connection is established * @param attempt - Current reconnection attempt number
* again. * @returns Delay in milliseconds
*
* @private * @private
*/ */
async function onError() { function getReconnectDelay(attempt: number): number {
console.warn("WebSocket disconnected, retrying in 3 seconds..."); const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attempt);
await delay(3000); return Math.min(delay, MAX_RECONNECT_DELAY);
websocket = create();
let listener: () => void | null;
listener = () => {
console.log("WebSocket successfully reconnected!");
websocket.removeEventListener("open", listener);
}
websocket.addEventListener("open", listener);
websocket.addEventListener("error", onError);
} }
// -------------- /**
// Initialization * Handle WebSocket reconnection with exponential backoff
// -------------- * @private
*/
async function reconnect(): Promise<void> {
if (isReconnecting) {
return;
}
websocket.addEventListener("message", (e) => { isReconnecting = true;
// Clean up old connection
cleanupWebSocket();
const delayMs = getReconnectDelay(reconnectAttempts);
reconnectAttempts++;
await delay(delayMs);
try {
websocket = create();
setupEventHandlers();
} catch (error) {
// If creation fails, try again
isReconnecting = false;
reconnect();
}
}
/**
* Setup event handlers for the WebSocket connection
* @private
*/
function setupEventHandlers(): void {
// Message handler
messageHandler = (e: MessageEvent) => {
try { try {
const response: WebSocketMessage<any> = JSON.parse(e.data); const response: WebSocketMessage<any> = JSON.parse(e.data);
@@ -152,5 +190,90 @@ websocket.addEventListener("message", (e) => {
} catch (error) { } catch (error) {
console.error("Error parsing WebSocket message:", error); console.error("Error parsing WebSocket message:", error);
} }
}); };
websocket.addEventListener("error", onError); websocket.addEventListener("message", messageHandler);
// Open handler
openHandler = () => {
reconnectAttempts = 0; // Reset on successful connection
isReconnecting = false;
};
websocket.addEventListener("open", openHandler);
// Error handler
errorHandler = () => {
// Don't reconnect immediately on error - let close handler handle it
// This prevents double reconnection attempts
};
websocket.addEventListener("error", errorHandler);
// Close handler
closeHandler = (e: CloseEvent) => {
// Don't reconnect if it was a clean close (e.g., logout, suspension)
if (e.code === 1000 || e.code === 1001) {
return;
}
// Reconnect for unexpected closes
if (!isReconnecting) {
reconnect();
}
};
websocket.addEventListener("close", closeHandler);
}
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error("Request timed out"));
}, 10000);
function requestInner() {
if (websocket.readyState !== WebSocket.OPEN) {
clearTimeout(timeoutId);
reject(new Error("WebSocket is not open"));
return;
}
const listener = (e: MessageEvent) => {
clearTimeout(timeoutId);
try {
resolve(JSON.parse(e.data));
} catch (error) {
reject(error);
}
websocket.removeEventListener("message", listener);
};
websocket.addEventListener("message", listener);
try {
websocket.send(JSON.stringify(payload));
} catch (error) {
clearTimeout(timeoutId);
websocket.removeEventListener("message", listener);
reject(error);
}
}
if (websocket.readyState === WebSocket.CONNECTING) {
const openListener = () => {
websocket.removeEventListener("open", openListener);
requestInner();
};
websocket.addEventListener("open", openListener);
} else if (websocket.readyState === WebSocket.OPEN) {
requestInner();
} else {
clearTimeout(timeoutId);
reject(new Error("WebSocket is closed"));
}
});
}
// --------------
// Initialization
// --------------
setupEventHandlers();
+1 -76
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;
@@ -121,7 +53,7 @@ button, input {
} }
&.warning { &.warning {
color: #ff9800; // Orange color for warnings color: $color-dark-tertiary; // Purple-themed warning color
} }
&.small mdui-icon { &.small mdui-icon {
@@ -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 -48
View File
@@ -1,63 +1,59 @@
@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%);
// custom colors
$color-1: rgb(82, 109, 246);
$color-2: rgb(65, 11, 113);
$color-4: rgb(95, 26, 198);
$color-3: rgb(49, 71, 179);
// Light // Light
$color-light-primary: rgb(31 101 134); $color-light-primary: rgb(31 101 134);
$color-light-surface-tint: rgb(31 101 134); $color-light-surface-tint: rgb(31 101 134);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 824 KiB

+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>
)
}
+281 -12
View File
@@ -1,29 +1,107 @@
@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;
.formWrapper {
position: absolute;
width: 100%;
top: 0;
left: 0;
.authHeader { .authHeader {
margin: 0; margin: 0;
padding: 16px; padding: 24px;
padding-bottom: 0; padding-bottom: 8px;
text-align: center; text-align: center;
h2 { h2 {
@@ -35,18 +113,209 @@
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
justify-content: center; justify-content: center;
font-weight: 600;
.material-symbols {
color: $color-dark-primary;
filter: drop-shadow(0 0 8px rgba($color-dark-primary, 0.4));
}
}
p {
color: $color-dark-on-surface-variant;
font-size: 0.95rem;
margin: 0;
} }
} }
.authBody { .authBody {
padding: 25px; padding: 24px;
padding-bottom: 16px; padding-bottom: 20px;
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; 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 {
@@ -25,16 +25,25 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 16px; padding: 16px;
user-select: none;
.logo {
$size: 35px;
width: $size;
height: $size;
margin-right: 8px;
}
.productName { .productName {
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 {
@@ -66,45 +75,11 @@
} }
} }
.chatTabs { .unifiedChatsList {
margin-top: 5px;
width: 100%;
height: calc(100% - 80px);
display: flex;
flex-direction: column;
min-height: 0; // prevent flex collapse when inner overflows
--mdui-color-surface: $color-dark-surface-container;
--mdui-color-surface-variant: transparent;
img {
width: 45px;
height: 45px;
border-radius: 20%;
object-fit: cover;
}
mdui-tabs {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0; // enable inner panel to scroll
}
mdui-tab-panel[active] {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; // critical to avoid collapsing
overflow: auto;
}
mdui-list {
flex: 1; flex: 1;
min-height: 0; // allow scroll area to size correctly min-height: 0; // allow scroll area to size correctly
overflow-y: auto; overflow-y: auto;
padding: 0; margin-top: 10px;
margin: 0;
}
} }
// Search container // Search container
@@ -134,6 +109,7 @@
padding: 32px; padding: 32px;
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
text-align: center; text-align: center;
overflow: hidden;
} }
// Custom styling for search result images // Custom styling for search result images
@@ -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,13 +5,14 @@ import { useState } from "react";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar"; import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
import styles from "@/pages/chat/css/left-panel.module.scss"; import styles from "@/pages/chat/css/left-panel.module.scss";
import logoIcon from "@/images/logo.svg";
export function ChatHeader() { export function ChatHeader({ headerRef }: { headerRef?: React.RefObject<HTMLElement | null> }) {
const { profileData } = useProfile(); const { profileData } = useProfile();
const { setProfileDialog, user } = useAppState(); const { setProfileDialog, user } = useAppState();
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
const handleProfileClick = () => { function handleProfileClick() {
setProfileDialog({ setProfileDialog({
userId: user.currentUser?.id, userId: user.currentUser?.id,
username: profileData?.username || "Пользователь", username: profileData?.username || "Пользователь",
@@ -26,7 +27,8 @@ export function ChatHeader() {
return ( return (
<> <>
<header className={styles.chatHeaderLeft}> <header className={styles.chatHeaderLeft} ref={headerRef}>
<img src={logoIcon} alt="Logo" className={styles.logo} />
<div className={styles.productName}>{PRODUCT_NAME}</div> <div className={styles.productName}>{PRODUCT_NAME}</div>
<div className={styles.profile}> <div className={styles.profile}>
<a href="#" id="profile-open" onClick={handleProfileClick}> <a href="#" id="profile-open" onClick={handleProfileClick}>
@@ -1,33 +0,0 @@
import { useAppState, type ChatTabs } from "@/pages/chat/state";
import { UnifiedChatsList } from "./UnifiedChatsList";
import { MaterialTab, MaterialTabPanel, MaterialTabs } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
export function ChatTabs() {
const { chat, setActiveTab } = useAppState();
return (
<div className={styles.chatTabs}>
<MaterialTabs
value={chat.activeTab}
full-width
onChange={(e) => setActiveTab(e.target.value as ChatTabs)}>
<MaterialTab value="chats">
Чаты
</MaterialTab>
<MaterialTab value="channels">
Каналы
</MaterialTab>
<MaterialTab value="contacts">
Контакты
</MaterialTab>
<MaterialTabPanel slot="panel" value="chats">
<UnifiedChatsList />
</MaterialTabPanel>
<MaterialTabPanel slot="panel" value="channels">Скоро будет...</MaterialTabPanel>
<MaterialTabPanel slot="panel" value="contacts">Скоро будет...</MaterialTabPanel>
</MaterialTabs>
</div>
);
}
+15 -12
View File
@@ -1,22 +1,21 @@
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { useState } from "react"; import { useRef, useState } from "react";
import { SettingsDialog } from "./settings/SettingsDialog"; import { SettingsDialog } from "./settings/SettingsDialog";
import { UsernameSearch } from "./UsernameSearch"; import { UsernameSearch } from "./UsernameSearch";
import { ChatTabs } from "./ChatTabs"; import { UnifiedChatsList } from "./UnifiedChatsList";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import { MaterialBottomAppBar, MaterialFab, MaterialIconButton } from "@/utils/material"; import { MaterialBottomAppBar, MaterialFab, MaterialIconButton, type MDUIBottomAppBar } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss"; import styles from "@/pages/chat/css/left-panel.module.scss";
function BottomAppBar() { function BottomAppBar({ bottomAppBarRef }: { bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null> }) {
const [settingsOpen, onSettingsOpenChange] = useState(false); const [settingsOpen, onSettingsOpenChange] = useState(false);
const { logout } = useAppState(); const { logout } = useAppState();
return ( return (
<> <>
<MaterialBottomAppBar> <MaterialBottomAppBar ref={bottomAppBarRef}>
<MaterialIconButton icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} /> <MaterialIconButton icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
<MaterialIconButton icon="group_add--filled" /> <div style={{ flexGrow: 1 }} />
<div style={{ flexGrow: 1 }}></div>
<MaterialIconButton <MaterialIconButton
icon="logout--filled" icon="logout--filled"
id="logout-btn" id="logout-btn"
@@ -30,14 +29,18 @@ function BottomAppBar() {
} }
export function LeftPanel() { export function LeftPanel() {
const containerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLElement>(null);
const bottomAppBarRef = useRef<MDUIBottomAppBar>(null);
return ( return (
<div className={styles.chatList} id="chat-list"> <div className={styles.chatList} ref={containerRef}>
<ChatHeader /> <ChatHeader headerRef={headerRef} />
<div className={styles.searchContainer}> <div className={styles.searchContainer}>
<UsernameSearch /> <UsernameSearch containerRef={containerRef} headerRef={headerRef} bottomAppBarRef={bottomAppBarRef} />
</div> </div>
<ChatTabs /> <UnifiedChatsList />
<BottomAppBar /> <BottomAppBar bottomAppBarRef={bottomAppBarRef} />
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
@@ -36,18 +36,17 @@ interface DMConversation {
type ChatItem = PublicChat | DMConversation; type ChatItem = PublicChat | DMConversation;
const PUBLIC_CHAT: PublicChat = {
id: "general",
name: "Общий чат",
type: "public"
};
export function UnifiedChatsList() { export function UnifiedChatsList() {
const { user, switchToPublicChat, switchToDM, chat } = useAppState(); const { user, switchToPublicChat, switchToDM, chat } = useAppState();
const { dmUsers, isLoadingUsers, loadUsers } = useDM(); const { dmUsers, isLoadingUsers, loadUsers } = useDM();
const [publicChats] = useState<PublicChat[]>([
{ id: "general", name: "Общий чат", type: "public" },
{ id: "general2", name: "Общий чат 2", type: "public" }
]);
const [lastMessages, setLastMessages] = useState<Record<string, Message | undefined>>({}); const [lastMessages, setLastMessages] = useState<Record<string, Message | undefined>>({});
const [allChats, setAllChats] = useState<ChatItem[]>([]);
// Load public chat last messages
const loadLastMessages = useCallback(async () => { const loadLastMessages = useCallback(async () => {
if (!user.authToken) return; if (!user.authToken) return;
@@ -58,13 +57,9 @@ export function UnifiedChatsList() {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data.messages && data.messages.length > 0) { if (data.messages?.length > 0) {
const lastMessage = data.messages[data.messages.length - 1]; const lastMessage = data.messages[data.messages.length - 1];
setLastMessages({ general: lastMessage });
setLastMessages({
general: lastMessage,
general2: lastMessage
});
} }
} }
} catch (error) { } catch (error) {
@@ -72,7 +67,6 @@ export function UnifiedChatsList() {
} }
}, [user.authToken]); }, [user.authToken]);
// Load DM users when chats tab is active
useEffect(() => { useEffect(() => {
if (chat.activeTab === "chats") { if (chat.activeTab === "chats") {
loadUsers(); loadUsers();
@@ -80,79 +74,56 @@ export function UnifiedChatsList() {
} }
}, [chat.activeTab, loadUsers, loadLastMessages]); }, [chat.activeTab, loadUsers, loadLastMessages]);
// Combine public chats and DMs into one list const allChats = useMemo<ChatItem[]>(() => {
useEffect(() => { return [
const publicChatItems: ChatItem[] = publicChats.map(chat => ({ ...dmUsers.map((user: DMUser) => ({
...chat, ...user,
lastMessage: lastMessages[chat.id] userId: user.id,
})); type: "dm" as const
})),
{
...PUBLIC_CHAT,
lastMessage: lastMessages[PUBLIC_CHAT.id]
}
];
}, [lastMessages, dmUsers]);
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
id: user.id,
userId: user.id, // Add userId field
username: user.username,
display_name: user.display_name,
profile_picture: user.profile_picture,
online: user.online,
type: "dm" as const,
lastMessage: user.lastMessage,
unreadCount: user.unreadCount,
publicKey: user.publicKey
}));
// Combine and sort by last message timestamp (DMs first, then public chats)
const combined = [...dmChatItems, ...publicChatItems];
setAllChats(combined);
}, [publicChats, lastMessages, dmUsers]);
// WebSocket listener for public chat message updates
useEffect(() => { useEffect(() => {
if (!websocket) return; if (!websocket) return;
const handleWebSocketMessage = (e: MessageEvent) => { function handleWebSocketMessage(e: MessageEvent) {
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === "newMessage") { if (msg.type === "newMessage") {
const newMessage = msg.data as Message; const newMessage = msg.data as Message;
// Update all public chats with the new message setLastMessages(prev => ({
setLastMessages(prev => { ...prev,
const updated = { ...prev }; [PUBLIC_CHAT.id]: newMessage
publicChats.forEach(chat => { }));
updated[chat.id] = newMessage;
});
return updated;
});
} else if (msg.type === "messageEdited") { } else if (msg.type === "messageEdited") {
const editedMessage = msg.data as Message; const editedMessage = msg.data as Message;
// Update only if the edited message is the current last message
setLastMessages(prev => { setLastMessages(prev => {
const updated = { ...prev }; if (prev[PUBLIC_CHAT.id]?.id === editedMessage.id) {
publicChats.forEach(chat => { return {
if (updated[chat.id]?.id === editedMessage.id) { ...prev,
updated[chat.id] = editedMessage; [PUBLIC_CHAT.id]: editedMessage
};
} }
}); return prev;
return updated;
}); });
} else if (msg.type === "messageDeleted") { } else if (msg.type === "messageDeleted") {
const deletedMessageId = msg.data?.message_id; const deletedMessageId = msg.data?.message_id;
let needsReload = false;
setLastMessages(prev => { setLastMessages(prev => {
const updated = { ...prev }; if (prev[PUBLIC_CHAT.id]?.id === deletedMessageId) {
publicChats.forEach(chat => {
if (updated[chat.id]?.id === deletedMessageId) {
updated[chat.id] = undefined;
needsReload = true;
}
});
return updated;
});
if (needsReload) {
loadLastMessages(); loadLastMessages();
return {
...prev,
[PUBLIC_CHAT.id]: undefined
};
} }
return prev;
});
} }
} catch (error) { } catch (error) {
console.error("Failed to handle WebSocket message in UnifiedChatsList:", error); console.error("Failed to handle WebSocket message in UnifiedChatsList:", error);
@@ -161,45 +132,33 @@ export function UnifiedChatsList() {
websocket.addEventListener("message", handleWebSocketMessage); websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage); return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [publicChats, loadLastMessages]); }, [loadLastMessages]);
// Subscribe to online status for all DM users
useEffect(() => { useEffect(() => {
const dmUsers = allChats.filter(chat => chat.type === "dm") as DMConversation[];
// Subscribe to all DM users
dmUsers.forEach(dmUser => { dmUsers.forEach(dmUser => {
onlineStatusManager.subscribe(dmUser.id); onlineStatusManager.subscribe(dmUser.id);
}); });
// Cleanup function to unsubscribe from all users
return () => { return () => {
dmUsers.forEach(dmUser => { dmUsers.forEach(dmUser => {
onlineStatusManager.unsubscribe(dmUser.id); onlineStatusManager.unsubscribe(dmUser.id);
}); });
}; };
}, [allChats]); }, [dmUsers]);
function formatPublicChatMessage(chatId: string): string { function formatPublicChatMessage(chatId: string): string {
const lastMessage = lastMessages[chatId]; const lastMessage = lastMessages[chatId];
if (!lastMessage) { if (!lastMessage) return "";
return "";
}
const isCurrentUser = lastMessage.user_id === user.currentUser?.id; const isCurrentUser = lastMessage.user_id === user.currentUser?.id;
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `; const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
const maxLength = 50 - prefix.length;
const maxContentLength = 50 - prefix.length; const content = lastMessage.content.length > maxLength
const content = lastMessage.content.length > maxContentLength ? lastMessage.content.substring(0, maxLength) + "..."
? lastMessage.content.substring(0, maxContentLength) + "..."
: lastMessage.content; : lastMessage.content;
return prefix + content; return prefix + content;
} };
async function handlePublicChatClick(chatName: string) {
await switchToPublicChat(chatName);
}
async function handleDMClick(dmConversation: DMConversation) { async function handleDMClick(dmConversation: DMConversation) {
if (!dmConversation.publicKey) { if (!dmConversation.publicKey) {
@@ -207,12 +166,11 @@ export function UnifiedChatsList() {
if (!authToken) return; if (!authToken) return;
const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); const publicKey = await fetchUserPublicKey(dmConversation.id, authToken);
if (publicKey) { if (!publicKey) {
dmConversation.publicKey = publicKey;
} else {
console.error("Failed to get public key for user:", dmConversation.id); console.error("Failed to get public key for user:", dmConversation.id);
return; return;
} }
dmConversation.publicKey = publicKey;
} }
await switchToDM({ await switchToDM({
@@ -222,26 +180,27 @@ export function UnifiedChatsList() {
profilePicture: dmConversation.profile_picture, profilePicture: dmConversation.profile_picture,
online: dmConversation.online || false online: dmConversation.online || false
}); });
} };
if (isLoadingUsers) { if (isLoadingUsers) {
return <MaterialCircularProgress />; return <MaterialCircularProgress />;
} }
return ( return (
<MaterialList> <MaterialList className={styles.unifiedChatsList}>
{allChats.map((chat) => { {allChats.map((chat) => {
if (chat.type === "public") { if (chat.type === "public") {
const formattedMessage = formatPublicChatMessage(chat.id);
return ( return (
<MaterialListItem <MaterialListItem
key={`public-${chat.id}`} key={`public-${chat.id}`}
headline={chat.name} headline={chat.name}
onClick={() => handlePublicChatClick(chat.name)} onClick={() => switchToPublicChat(chat.name)}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
{formatPublicChatMessage(chat.id) && ( {formattedMessage && (
<span slot="description" className={styles.listDescription}> <span slot="description" className={styles.listDescription}>
{formatPublicChatMessage(chat.id)} {formattedMessage}
</span> </span>
)} )}
<img <img
@@ -257,7 +216,8 @@ export function UnifiedChatsList() {
/> />
</MaterialListItem> </MaterialListItem>
); );
} else { }
return ( return (
<MaterialListItem <MaterialListItem
key={`dm-${chat.id}`} key={`dm-${chat.id}`}
@@ -300,7 +260,6 @@ export function UnifiedChatsList() {
)} )}
</MaterialListItem> </MaterialListItem>
); );
}
})} })}
</MaterialList> </MaterialList>
); );
@@ -8,7 +8,7 @@ import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import { OnlineStatus } from "@/pages/chat/ui/right/OnlineStatus"; import { OnlineStatus } from "@/pages/chat/ui/right/OnlineStatus";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import SearchBar from "@/core/components/SearchBar"; import SearchBar from "@/core/components/SearchBar";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material"; import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem, type MDUIBottomAppBar } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss"; import styles from "@/pages/chat/css/left-panel.module.scss";
interface SearchUser extends User { interface SearchUser extends User {
@@ -16,7 +16,13 @@ interface SearchUser extends User {
verified?: boolean; verified?: boolean;
} }
export function UsernameSearch() { export interface UsernameSearchProps {
containerRef: React.RefObject<HTMLElement | null>;
headerRef?: React.RefObject<HTMLElement | null>;
bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null>;
}
export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) {
const { user, switchToDM, chat } = useAppState(); const { user, switchToDM, chat } = useAppState();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchUser[]>([]); const [searchResults, setSearchResults] = useState<SearchUser[]>([]);
@@ -165,6 +171,9 @@ export function UsernameSearch() {
icon="arrow_back--outlined" icon="arrow_back--outlined"
/> />
) : "search--outlined"} ) : "search--outlined"}
containerRef={containerRef}
headerRef={headerRef}
bottomAppBarRef={bottomAppBarRef}
> >
{isSearching && ( {isSearching && (
<div className={styles.searchLoading}> <div className={styles.searchLoading}>
+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>;