Migrate to SCSS modules

This commit is contained in:
2025-11-01 19:29:36 +03:00
Unverified
parent 4c4d809e9b
commit 0921ec875c
60 changed files with 1802 additions and 1889 deletions
+1
View File
@@ -574,3 +574,4 @@ backend/alembic/**
!backend/alembic/env.py !backend/alembic/env.py
!backend/alembic/script.py.mako !backend/alembic/script.py.mako
!frontend/src/css/lib !frontend/src/css/lib
**/*.module.scss.d.ts
+3 -5
View File
@@ -1,9 +1,7 @@
{ {
"files.exclude": { "files.exclude": {
"**/__pycache__": true, "**/__pycache__": true,
"**/package-lock.json": true "**/package-lock.json": true,
}, "**/*.module.scss.d.ts": true
"github-actions.workflows.pinned.workflows": [], }
"github-actions.workflows.pinned.workflows.ignore": true,
"github-actions.workflows.pinned.workflows.ignoreContextAccess": true
} }
+10 -10
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import "./css/searchBar.scss"; import styles from "./css/searchBar.module.scss";
import { MaterialIcon } from "@/utils/material"; import { MaterialIcon } from "@/utils/material";
interface SearchBarProps { interface SearchBarProps {
@@ -69,47 +69,47 @@ export default function SearchBar({
return ( return (
<div <div
ref={parentContainerRef} ref={parentContainerRef}
className="search-parent" className={styles.searchParent}
> >
<div <div
ref={searchContainerRef} ref={searchContainerRef}
className={`search-bar-container ${isExpanded ? "expanded" : "collapsed"}`} className={`${styles.searchBarContainer} ${isExpanded ? styles.expanded : styles.collapsed}`}
style={{ height: dynamicHeight }} style={{ height: dynamicHeight }}
onClick={!isExpanded ? handleToggle : undefined} onClick={!isExpanded ? handleToggle : undefined}
> >
{/* Single Search Bar Element */} {/* Single Search Bar Element */}
<div className="search-bar"> <div className={styles.searchBar}>
{/* Left Icon */} {/* Left Icon */}
<div className="search-icon"> <div className={styles.searchIcon}>
{renderIcon(leftIcon, "search--outlined")} {renderIcon(leftIcon, "search--outlined")}
</div> </div>
{/* Input/Placeholder */} {/* Input/Placeholder */}
<div className="search-input-container"> <div className={styles.searchInputContainer}>
{isExpanded ? ( {isExpanded ? (
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder={placeholder} placeholder={placeholder}
className="search-input" className={styles.searchInput}
value={searchQuery} value={searchQuery}
onChange={handleQueryChange} onChange={handleQueryChange}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : ( ) : (
<span className="search-placeholder">{placeholder}</span> <span className={styles.searchPlaceholder}>{placeholder}</span>
)} )}
</div> </div>
{/* Right Icon */} {/* Right Icon */}
<div className="search-clear"> <div className={styles.searchClear}>
{renderIcon(rightIcon)} {renderIcon(rightIcon)}
</div> </div>
</div> </div>
{/* Results Section - Only visible when expanded */} {/* Results Section - Only visible when expanded */}
{isExpanded && ( {isExpanded && (
<div className="search-results"> <div className={styles.searchResults}>
{children} {children}
</div> </div>
)} )}
@@ -1,6 +1,7 @@
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useEffect, type ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { motion, AnimatePresence, type Transition } from "motion/react"; import { motion, AnimatePresence, type Transition } from "motion/react";
import styles from "./css/styled-dialog.module.scss";
interface StyledDialogProps { interface StyledDialogProps {
open: boolean; open: boolean;
@@ -8,6 +9,7 @@ interface StyledDialogProps {
children: ReactNode; children: ReactNode;
onBackdropClick?: () => void; onBackdropClick?: () => void;
className?: string; className?: string;
contentClassName?: string;
afterChildren?: ReactNode; afterChildren?: ReactNode;
} }
@@ -17,6 +19,7 @@ export function StyledDialog({
children, children,
onBackdropClick, onBackdropClick,
className = "", className = "",
contentClassName = "",
afterChildren afterChildren
}: StyledDialogProps) { }: StyledDialogProps) {
const transition: Transition = { duration: 0.3, type: "tween", ease: "easeInOut" }; const transition: Transition = { duration: 0.3, type: "tween", ease: "easeInOut" };
@@ -39,7 +42,7 @@ export function StyledDialog({
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<motion.div <motion.div
className={`styled-dialog-backdrop ${className}`} className={styles.styledDialogBackdrop}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
if (onBackdropClick) { if (onBackdropClick) {
@@ -54,12 +57,12 @@ export function StyledDialog({
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={transition}> transition={transition}>
<motion.div <motion.div
className={`styled-dialog ${className}`} className={`${styles.styledDialog} ${className}`}
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }} exit={{ scale: 0.9, opacity: 0 }}
transition={transition}> transition={transition}>
<div className="styled-dialog-content"> <div className={`${styles.styledDialogContent} ${contentClassName}`}>
{children} {children}
</div> </div>
{afterChildren} {afterChildren}
@@ -3,17 +3,19 @@
$font-size: 16px; $font-size: 16px;
// Search container // Search container
.search-parent { .searchParent {
position: relative; position: relative;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
// SearchBar component styles // SearchBar component styles
.search-bar-container { .searchBarContainer {
position: absolute; position: absolute;
z-index: 1001; z-index: 1001;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
// All properties animate together simultaneously // All properties animate together simultaneously
transition: transition:
@@ -47,7 +49,7 @@ $font-size: 16px;
} }
// Single search bar element // Single search bar element
.search-bar { .searchBar {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 16px; padding: 0 16px;
@@ -55,7 +57,7 @@ $font-size: 16px;
gap: 12px; gap: 12px;
cursor: pointer; cursor: pointer;
.search-icon { .searchIcon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -69,18 +71,18 @@ $font-size: 16px;
} }
} }
.search-input-container { .searchInputContainer {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
.search-placeholder { .searchPlaceholder {
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
font-size: $font-size; font-size: $font-size;
pointer-events: none; pointer-events: none;
} }
.search-input { .searchInput {
flex: 1; flex: 1;
border: none; border: none;
outline: none; outline: none;
@@ -96,7 +98,7 @@ $font-size: 16px;
} }
} }
.search-clear { .searchClear {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -112,43 +114,9 @@ $font-size: 16px;
} }
// Results section // Results section
.search-results { .searchResults {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
.search-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px;
color: $color-dark-on-surface-variant;
mdui-circular-progress {
--mdui-circular-progress-color: $color-dark-primary;
}
}
.search-empty,
.search-hint {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
color: $color-dark-on-surface-variant;
text-align: center;
}
// Custom styling for search result images
mdui-list-item {
img[slot="icon"] {
$size: 48px;
width: $size;
height: $size;
border-radius: 50%;
object-fit: cover;
}
}
} }
} }
@@ -6,7 +6,7 @@
$dialog-padding: 30px; $dialog-padding: 30px;
// Base Styled Dialog Styles // Base Styled Dialog Styles
.styled-dialog-backdrop { .styledDialogBackdrop {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@@ -21,10 +21,10 @@ $dialog-padding: 30px;
padding: $dialog-padding; padding: $dialog-padding;
box-sizing: border-box; box-sizing: border-box;
.styled-dialog { .styledDialog {
width: 100%; width: 100%;
max-width: 500px; max-width: 500px;
max-height: calc(100vh - $dialog-padding * 2); max-height: calc(100vh - #{$dialog-padding} * 2);
background: rgba($color-dark-surface-container, 0.75); background: rgba($color-dark-surface-container, 0.75);
border: 1px solid rgba($color-dark-outline-variant, 0.3); border: 1px solid rgba($color-dark-outline-variant, 0.3);
border-radius: 16px; border-radius: 16px;
@@ -36,7 +36,7 @@ $dialog-padding: 30px;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
.styled-dialog-content { .styledDialogContent {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
-2
View File
@@ -2,7 +2,6 @@
@use "components"; @use "components";
@use "colors" as *; @use "colors" as *;
@use "material" as *; @use "material" as *;
@use "../core/components/css/styled-dialog";
@use "fonts/montserrat"; @use "fonts/montserrat";
@use "fonts/material-symbols"; @use "fonts/material-symbols";
@@ -17,7 +16,6 @@ body {
background-color: $color-dark-surface; background-color: $color-dark-surface;
color: $color-dark-on-surface; color: $color-dark-on-surface;
line-height: 1.6; line-height: 1.6;
overflow: hidden;
#main-wrapper { #main-wrapper {
flex: 1; flex: 1;
+4 -3
View File
@@ -1,9 +1,10 @@
import type React from "react"; import type React from "react";
import styles from "./auth.module.scss";
export function AuthContainer({ children }: { children?: React.ReactNode }) { export function AuthContainer({ children }: { children?: React.ReactNode }) {
return ( return (
<div className="auth-container"> <div className={styles.authContainer}>
<div className="auth-card fade-in"> <div className={styles.authCard}>
{children} {children}
</div> </div>
</div> </div>
@@ -28,7 +29,7 @@ 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="auth-header"> <div className={styles.authHeader}>
<h2> <h2>
<span className={`material-symbols ${iconType} large`}>{iconName}</span> <span className={`material-symbols ${iconType} large`}>{iconName}</span>
{title} {title}
+2 -4
View File
@@ -10,7 +10,7 @@ import { useAppState } from "@/pages/chat/state";
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; import { isElectron } from "@/core/electron/electron";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./auth.scss"; import styles from "./auth.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialTextField } from "@/utils/material"; import { MaterialButton, MaterialTextField } from "@/utils/material";
@@ -31,7 +31,7 @@ export default function LoginPage() {
return ( return (
<AuthContainer> <AuthContainer>
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" /> <AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
<div className="auth-body"> <div className={styles.authBody}>
<AlertsContainer alerts={alerts} /> <AlertsContainer alerts={alerts} />
<form <form
@@ -117,7 +117,6 @@ export default function LoginPage() {
<MaterialTextField <MaterialTextField
label="@Имя пользователя" label="@Имя пользователя"
id="login-username"
name="username" name="username"
variant="outlined" variant="outlined"
icon="person--filled" icon="person--filled"
@@ -127,7 +126,6 @@ export default function LoginPage() {
<MaterialTextField <MaterialTextField
label="Пароль" label="Пароль"
id="login-password"
name="password" name="password"
variant="outlined" variant="outlined"
type="password" type="password"
+2 -6
View File
@@ -9,7 +9,7 @@ import { useAppState } from "@/pages/chat/state";
import { MaterialButton, MaterialTextField } from "@/utils/material"; import { MaterialButton, MaterialTextField } from "@/utils/material";
import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./auth.scss"; import styles from "./auth.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
export default function RegisterPage() { export default function RegisterPage() {
@@ -31,7 +31,7 @@ export default function RegisterPage() {
return ( return (
<AuthContainer> <AuthContainer>
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" /> <AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
<div className="auth-body"> <div className={styles.authBody}>
<AlertsContainer alerts={alerts} /> <AlertsContainer alerts={alerts} />
<form onSubmit={async (e) => { <form onSubmit={async (e) => {
@@ -113,7 +113,6 @@ export default function RegisterPage() {
}}> }}>
<MaterialTextField <MaterialTextField
label="Отображаемое имя" label="Отображаемое имя"
id="register-display-name"
name="display_name" name="display_name"
variant="outlined" variant="outlined"
icon="badge--filled" icon="badge--filled"
@@ -124,7 +123,6 @@ export default function RegisterPage() {
ref={displayNameElement} /> ref={displayNameElement} />
<MaterialTextField <MaterialTextField
label="@Имя пользователя" label="@Имя пользователя"
id="register-username"
name="username" name="username"
variant="outlined" variant="outlined"
icon="person--filled" icon="person--filled"
@@ -135,7 +133,6 @@ export default function RegisterPage() {
ref={usernameElement} /> ref={usernameElement} />
<MaterialTextField <MaterialTextField
label="Пароль" label="Пароль"
id="register-password"
name="password" name="password"
variant="outlined" variant="outlined"
type="password" type="password"
@@ -146,7 +143,6 @@ export default function RegisterPage() {
ref={passwordElement} /> ref={passwordElement} />
<MaterialTextField <MaterialTextField
label="Подтвердите пароль" label="Подтвердите пароль"
id="register-confirm-password"
name="confirm_password" name="confirm_password"
variant="outlined" variant="outlined"
type="password" type="password"
@@ -1,7 +1,7 @@
@use "../../css/colors" as *; @use "../../css/colors" as *;
@use "../../css/material" as *; @use "../../css/material" as *;
.auth-container { .authContainer {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -9,7 +9,7 @@
padding: 2rem; padding: 2rem;
background-color: $color-dark-surface; background-color: $color-dark-surface;
.auth-card { .authCard {
background-color: $color-dark-surface-container; background-color: $color-dark-surface-container;
color: $color-dark-on-surface; color: $color-dark-on-surface;
border-radius: 12px; border-radius: 12px;
@@ -17,9 +17,10 @@
width: 100%; width: 100%;
max-width: 450px; max-width: 450px;
overflow: hidden; overflow: hidden;
animation: authCardAnimation 0.3s ease-in-out;
} }
.auth-header { .authHeader {
margin: 0; margin: 0;
padding: 16px; padding: 16px;
padding-bottom: 0; padding-bottom: 0;
@@ -37,7 +38,7 @@
} }
} }
.auth-body { .authBody {
padding: 25px; padding: 25px;
padding-bottom: 16px; padding-bottom: 16px;
@@ -48,3 +49,4 @@
} }
} }
} }
@@ -0,0 +1,141 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Reply preview styles (shared with Message component)
.quote.contextualContent > .quoteInner {
display: flex;
flex-direction: column;
gap: 4px;
.replyUsername {
font-weight: 600;
color: $color-dark-on-surface;
font-size: 0.85rem;
}
.replyText {
overflow: hidden;
text-overflow: ellipsis;
}
}
.chatInputWrapper {
position: relative;
margin: 0 10px 10px 10px;
position: sticky;
bottom: 10px;
z-index: 1;
.inputGroup {
display: flex;
background: rgba($color-dark-surface-container, 0.7);
border-radius: 30px;
flex-direction: column;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(20px);
.contextualPreview {
padding: 12px 16px 0 16px;
display: flex;
align-items: flex-start;
gap: 16px;
mdui-icon {
align-self: center;
box-sizing: content-box;
}
.replyCancel {
margin-left: auto;
}
}
.attachmentsPreview {
align-items: center;
.attachmentsChips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.chatInput {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
.buttons, .leftButtons {
display: flex;
flex-direction: row;
align-items: center;
}
.leftButtons {
.emojiBtn {
margin: 10px;
color: $color-dark-on-surface-variant;
transition: color 0.2s ease;
flex-shrink: 0;
align-self: flex-end;
&:hover {
color: $color-dark-primary;
}
}
}
.messageInput {
flex: 1;
padding: 20px 0;
border: none;
border-radius: 25px;
font-size: 1rem;
outline: none;
caret-color: $color-dark-primary;
color: $color-dark-on-surface;
background: transparent;
resize: none;
font: inherit;
font-size: 13pt;
height: 100%;
width: 100%;
&::placeholder {
color: $color-dark-on-surface-variant;
opacity: 0.7;
}
}
.buttons {
.sendBtn {
margin: 10px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
color: $color-dark-on-primary;
border: 1px solid rgba($color-dark-primary, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
align-self: flex-end;
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
&:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba($color-dark-primary, 0.6);
}
}
}
}
}
}
// Typing indicator styles
@@ -2,127 +2,8 @@
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
.chat-input-wrapper {
position: relative;
margin: 0 10px 10px 10px;
position: sticky;
bottom: 10px;
z-index: 1;
.input-group {
display: flex;
background: rgba($color-dark-surface-container, 0.7);
border-radius: 30px;
flex-direction: column;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(20px);
.contextual-preview {
padding: 12px 16px 0 16px;
display: flex;
align-items: flex-start;
gap: 16px;
mdui-icon {
align-self: center;
box-sizing: content-box;
}
.reply-cancel {
margin-left: auto;
}
}
.attachments-preview {
align-items: center;
.attachments-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.chat-input {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
.buttons, .left-buttons {
display: flex;
flex-direction: row;
align-items: center;
}
.left-buttons {
.emoji-btn {
margin: 10px;
color: $color-dark-on-surface-variant;
transition: color 0.2s ease;
flex-shrink: 0;
align-self: flex-end;
&:hover {
color: $color-dark-primary;
}
}
}
.message-input {
flex: 1;
padding: 20px 0;
border: none;
border-radius: 25px;
font-size: 1rem;
outline: none;
caret-color: $color-dark-primary;
color: $color-dark-on-surface;
background: transparent;
resize: none;
font: inherit;
font-size: 13pt;
height: 100%;
width: 100%;
&::placeholder {
color: $color-dark-on-surface-variant;
opacity: 0.7;
}
}
.buttons {
.send-btn {
margin: 10px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
color: $color-dark-on-primary;
border: 1px solid rgba($color-dark-primary, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
align-self: flex-end;
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
&:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba($color-dark-primary, 0.6);
}
}
}
}
}
}
// Typing indicator styles
// Emoji Menu Styles // Emoji Menu Styles
.emoji-menu { .emojiMenu {
$transition: cubic-bezier(0.4, 0, 0.2, 1); $transition: cubic-bezier(0.4, 0, 0.2, 1);
background: $color-dark-surface-container; background: $color-dark-surface-container;
@@ -146,12 +27,12 @@
transform: translateY(0); transform: translateY(0);
} }
.emoji-menu-header { .emojiMenuHeader {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
.emoji-category-tabs { .emojiCategoryTabs {
$scrollbar-height: 2px; $scrollbar-height: 2px;
display: flex; display: flex;
@@ -180,7 +61,7 @@
background-clip: padding-box; // keep color inside the border background-clip: padding-box; // keep color inside the border
} }
.emoji-category-tab { .emojiCategoryTab {
background-color: transparent; background-color: transparent;
border: none; border: none;
border-radius: 10px; border-radius: 10px;
@@ -217,7 +98,7 @@
} }
} }
.emoji-grid { .emojiGrid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
@@ -237,8 +118,8 @@
border-radius: 3px; border-radius: 3px;
} }
.emoji-category-section { .emojiCategorySection {
.emoji-category-title { .emojiCategoryTitle {
position: sticky; position: sticky;
top: 5px; top: 5px;
padding: 5px 12px; padding: 5px 12px;
@@ -253,7 +134,7 @@
background-color: rgba($color-dark-surface-container-high, 0.7); background-color: rgba($color-dark-surface-container-high, 0.7);
} }
.emoji-category-grid { .emojiCategoryGrid {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
@@ -262,7 +143,7 @@
} }
} }
.emoji-item { .emojiItem {
$size: 30px; $size: 30px;
background: transparent; background: transparent;
@@ -290,7 +171,7 @@
} }
} }
.emoji-empty-state { .emojiEmptyState {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
@@ -2,18 +2,18 @@
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
.quote.contextual-content > .quote-inner { .quote.contextualContent > .quoteInner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
.reply-username { .replyUsername {
font-weight: 600; font-weight: 600;
color: $color-dark-on-surface; color: $color-dark-on-surface;
font-size: 0.85rem; font-size: 0.85rem;
} }
.reply-text { .replyText {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@@ -31,7 +31,7 @@
gap: 8px; gap: 8px;
&.received { &.received {
.message-profile-pic { .messageProfilePic {
width: 40px; width: 40px;
height: 40px; height: 40px;
flex-shrink: 0; flex-shrink: 0;
@@ -52,7 +52,7 @@
} }
} }
.message-inner { .messageInner {
border-radius: 12px; border-radius: 12px;
position: relative; position: relative;
word-wrap: break-word; word-wrap: break-word;
@@ -62,7 +62,7 @@
max-width: 100%; max-width: 100%;
display: inline-block; display: inline-block;
.message-username { .messageUsername {
font-weight: 600; font-weight: 600;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
font-size: 0.9rem; font-size: 0.9rem;
@@ -78,7 +78,7 @@
} }
} }
.message-content { .messageContent {
word-wrap: break-word; word-wrap: break-word;
margin: 10px 10px 0 10px; margin: 10px 10px 0 10px;
white-space: pre-wrap; white-space: pre-wrap;
@@ -92,12 +92,12 @@
} }
} }
.quote.reply-preview { .quote.replyPreview {
user-select: none; user-select: none;
margin: 10px; margin: 10px;
} }
.message-attachments { .messageAttachments {
padding: 5px 0 0 0; padding: 5px 0 0 0;
overflow: hidden; overflow: hidden;
@@ -106,7 +106,7 @@
text-decoration: none; text-decoration: none;
} }
.attachement-image { .attachementImage {
max-width: 200px; max-width: 200px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
@@ -124,17 +124,17 @@
} }
} }
.attachement-image.placeholder { .attachementImage.placeholder {
background: $color-dark-surface-container-highest; background: $color-dark-surface-container-highest;
pointer-events: none; pointer-events: none;
} }
.image-wrapper { .imageWrapper {
position: relative; position: relative;
display: inline-block; display: inline-block;
} }
.loading-overlay { .loadingOverlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
@@ -145,7 +145,7 @@
border-radius: 8px; border-radius: 8px;
} }
.preload-image { .preloadImage {
position: absolute; position: absolute;
width: 0; width: 0;
height: 0; height: 0;
@@ -153,7 +153,7 @@
pointer-events: none; pointer-events: none;
} }
.with-icon-gap { .withIconGap {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -161,7 +161,7 @@
} }
} }
.message-time { .messageTime {
font-size: 0.7rem; font-size: 0.7rem;
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
margin-top: 0.3rem; margin-top: 0.3rem;
@@ -173,13 +173,13 @@
justify-content: flex-end; justify-content: flex-end;
gap: 4px; gap: 4px;
.message-status-indicator { .messageStatusIndicator {
display: flex; display: flex;
align-items: center; align-items: center;
width: $status-indicator-size; width: $status-indicator-size;
height: $status-indicator-size; height: $status-indicator-size;
.error-icon, .success-icon { .errorIcon, .successIcon {
font-size: $status-indicator-size; font-size: $status-indicator-size;
width: $status-indicator-size; width: $status-indicator-size;
height: $status-indicator-size; height: $status-indicator-size;
@@ -194,7 +194,7 @@
} }
&.received { &.received {
.message-inner { .messageInner {
background: $color-dark-surface-container; background: $color-dark-surface-container;
color: $color-dark-on-surface; color: $color-dark-on-surface;
border-top-left-radius: 5px; border-top-left-radius: 5px;
@@ -221,7 +221,7 @@
} }
} }
.message-time { .messageTime {
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
font-weight: 500; font-weight: 500;
} }
@@ -231,7 +231,7 @@
margin-left: auto; margin-left: auto;
flex-direction: row-reverse; flex-direction: row-reverse;
.message-inner { .messageInner {
background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%)); background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
color: $color-dark-on-primary; color: $color-dark-on-primary;
border-top-right-radius: 5px; border-top-right-radius: 5px;
@@ -258,29 +258,29 @@
} }
} }
&.emoji-message { &.emojiMessage {
.message-inner { .messageInner {
align-items: flex-end; align-items: flex-end;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.message-content { .messageContent {
&.emoji-content { &.emojiContent {
text-align: right; text-align: right;
} }
} }
} }
} }
.message-time { .messageTime {
color: $color-dark-on-primary; color: $color-dark-on-primary;
font-weight: 500; font-weight: 500;
} }
} }
// Emoji message styles // Emoji message styles
&.emoji-message { &.emojiMessage {
.message-inner { .messageInner {
background: transparent; background: transparent;
border: none; border: none;
box-shadow: none; box-shadow: none;
@@ -292,24 +292,24 @@
} }
} }
.message-content { .messageContent {
margin: 0; margin: 0;
padding: 0; padding: 0;
text-align: right; text-align: right;
&.emoji-content { &.emojiContent {
font-size: 2rem; font-size: 2rem;
line-height: 1.2; line-height: 1.2;
} }
&.single-emoji-content { &.singleEmojiContent {
font-size: 4rem; font-size: 4rem;
line-height: 1; line-height: 1;
margin-bottom: 8px; margin-bottom: 8px;
} }
} }
.message-time { .messageTime {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border-radius: 12px; border-radius: 12px;
padding: 4px 8px; padding: 4px 8px;
@@ -328,7 +328,7 @@
} }
} }
.message-username { .messageUsername {
&.loading { &.loading {
opacity: 0.6; opacity: 0.6;
cursor: default; cursor: default;
@@ -336,7 +336,7 @@
} }
// Fullscreen Image Viewer // Fullscreen Image Viewer
.fullscreen-image-overlay { .fullscreenImageOverlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
width: 100vw; width: 100vw;
@@ -351,7 +351,7 @@
opacity: 0; opacity: 0;
} }
.fullscreen-animated-image { .fullscreenAnimatedImage {
position: absolute; position: absolute;
object-fit: contain; object-fit: contain;
border-radius: 12px; border-radius: 12px;
@@ -359,18 +359,18 @@
transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease; transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease;
} }
.fullscreen-controls { .fullscreenControls {
position: absolute; position: absolute;
display: flex; display: flex;
gap: 8px; gap: 8px;
&.top-right { &.topRight {
top: 12px; top: 12px;
right: 12px; right: 12px;
} }
} }
.progress-wrapper { .progressWrapper {
width: 40px; width: 40px;
height: 40px; height: 40px;
display: flex; display: flex;
@@ -380,8 +380,8 @@
} }
// Mention link styling // Mention link styling
.message-content { .messageContent {
.mention-link { .mentionLink {
color: $color-dark-primary; color: $color-dark-primary;
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
@@ -400,3 +400,97 @@
} }
} }
} }
// Reaction styles
.messageReactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
margin-left: 10px;
margin-right: 10px;
animation: messageReactionsFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.reactionButton {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: none;
border-radius: 16px;
background-color: $color-dark-surface-container;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
font-size: 1px;
min-height: 28px;
animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&.removing {
animation: reactionFadeOut 0.2s ease forwards;
}
&:hover {
background-color: $color-dark-surface-container-high;
transform: scale(1.05);
}
&.reacted {
background-color: $color-dark-primary-container;
border-color: $color-dark-primary;
color: $color-dark-on-primary-container;
&:hover {
background-color: color.adjust($color-dark-primary-container, $lightness: 20%);
}
}
}
.reactionEmoji {
font-size: 17px;
line-height: 1;
}
.reactionCount {
font-size: 12px;
font-weight: 500;
line-height: 1;
}
// Animation for reactions appearing/disappearing
@keyframes messageReactionsFadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes reactionFadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes reactionFadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.8);
}
}
@@ -10,7 +10,7 @@
} }
// Context menu reaction bar - main container // Context menu reaction bar - main container
.context-menu-reaction-bar { .contextMenuReactionBar {
position: fixed; position: fixed;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -29,19 +29,19 @@
animation: reactionBarEnter 0.2s ease forwards; animation: reactionBarEnter 0.2s ease forwards;
} }
&.entering-left { &.enteringLeft {
opacity: 0; opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.9); transform: translateX(-20px) translateY(20px) scale(0.9);
animation: reactionBarEnterLeft 0.2s ease forwards; animation: reactionBarEnterLeft 0.2s ease forwards;
} }
&.entering-up { &.enteringUp {
opacity: 0; opacity: 0;
transform: translateY(-20px) scale(0.9); transform: translateY(-20px) scale(0.9);
animation: reactionBarEnterUp 0.2s ease forwards; animation: reactionBarEnterUp 0.2s ease forwards;
} }
&.entering-right { &.enteringRight {
opacity: 0; opacity: 0;
transform: translateX(20px) scale(0.9); transform: translateX(20px) scale(0.9);
animation: reactionBarEnterRight 0.2s ease forwards; animation: reactionBarEnterRight 0.2s ease forwards;
@@ -53,13 +53,13 @@
animation: reactionBarClose 0.2s ease forwards; animation: reactionBarClose 0.2s ease forwards;
} }
&.closing-left { &.closingLeft {
opacity: 1; opacity: 1;
transform: translateX(0) translateY(0) scale(1); transform: translateX(0) translateY(0) scale(1);
animation: reactionBarCloseLeft 0.2s ease forwards; animation: reactionBarCloseLeft 0.2s ease forwards;
} }
&.closing-up { &.closingUp {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
animation: reactionBarCloseUp 0.2s ease forwards; animation: reactionBarCloseUp 0.2s ease forwards;
@@ -75,7 +75,7 @@
border-radius: 16px; border-radius: 16px;
// Emoji menu wrapper inside expanded reaction bar // Emoji menu wrapper inside expanded reaction bar
.emoji-menu-wrapper { .emojiMenuWrapper {
width: 320px; width: 320px;
height: 400px; height: 400px;
display: flex; display: flex;
@@ -85,13 +85,13 @@
} }
// Upward expansion animation // Upward expansion animation
&.expand-upward .emoji-menu-wrapper { &.expandUpward .emojiMenuWrapper {
animation: emojiMenuEnterUp 0.3s ease !important; animation: emojiMenuEnterUp 0.3s ease !important;
} }
} }
// Reaction bar content - contains emoji buttons // Reaction bar content - contains emoji buttons
.reaction-bar-content { .reactionBarContent {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -103,7 +103,7 @@
} }
// Individual emoji buttons // Individual emoji buttons
.reaction-emoji-button { .reactionEmojiButton {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -129,7 +129,7 @@
} }
// Expand button for emoji menu // Expand button for emoji menu
.reaction-expand-button { .reactionExpandButton {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -153,20 +153,20 @@
transition: transform 0.1s ease; transition: transform 0.1s ease;
} }
.material-symbols { :global(.material-symbols) {
font-size: 18px; font-size: 18px;
color: var(--mdui-color-on-surface); color: var(--mdui-color-on-surface);
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
&:hover .material-symbols { &:hover :global(.material-symbols) {
transform: rotate(90deg); transform: rotate(90deg);
} }
} }
} }
// Context menu - separate element // Context menu - separate element
.context-menu { .contextMenu {
position: fixed; position: fixed;
@extend %background; @extend %background;
border-radius: 16px; border-radius: 16px;
@@ -184,15 +184,15 @@
animation: fadeInDown 0.2s ease forwards; animation: fadeInDown 0.2s ease forwards;
} }
&.entering-left { &.enteringLeft {
animation: fadeInLeft 0.2s ease forwards; animation: fadeInLeft 0.2s ease forwards;
} }
&.entering-up { &.enteringUp {
animation: fadeInUp 0.2s ease forwards; animation: fadeInUp 0.2s ease forwards;
} }
&.entering-up-left { &.enteringUpLeft {
animation: fadeInUpLeft 0.2s ease forwards; animation: fadeInUpLeft 0.2s ease forwards;
} }
@@ -200,15 +200,15 @@
animation: fadeOutUp 0.2s ease forwards; animation: fadeOutUp 0.2s ease forwards;
} }
&.closing-left { &.closingLeft {
animation: fadeOutRight 0.2s ease forwards; animation: fadeOutRight 0.2s ease forwards;
} }
&.closing-up { &.closingUp {
animation: fadeOutDown 0.2s ease forwards; animation: fadeOutDown 0.2s ease forwards;
} }
&.closing-up-left { &.closingUpLeft {
animation: fadeOutDownRight 0.2s ease forwards; animation: fadeOutDownRight 0.2s ease forwards;
} }
@@ -217,7 +217,7 @@
} }
// Context menu items // Context menu items
.context-menu-item { .contextMenuItem {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -236,8 +236,248 @@
transform: scale(0.95); transform: scale(0.95);
} }
.material-symbols { :global(.material-symbols) {
font-size: 18px; font-size: 18px;
} }
} }
} }
// Context menu wrapper animations
@keyframes contextMenuEnter {
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes contextMenuEnterLeft {
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes contextMenuEnterUp {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes contextMenuEnterUpLeft {
to {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
}
}
@keyframes contextMenuClose {
to {
opacity: 0;
transform: scale(0.8);
}
}
@keyframes contextMenuCloseLeft {
to {
opacity: 0;
transform: translateX(-20px) scale(0.8);
}
}
@keyframes contextMenuCloseUp {
to {
opacity: 0;
transform: translateY(20px) scale(0.8);
}
}
@keyframes contextMenuCloseUpLeft {
to {
opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.8);
}
}
@keyframes emojiMenuEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes emojiMenuEnterDown {
from {
opacity: 0;
transform: scaleY(0);
transform-origin: top;
}
to {
opacity: 1;
transform: scaleY(1);
}
}
@keyframes emojiMenuEnterUp {
from {
opacity: 0;
transform: scaleY(0);
transform-origin: bottom;
}
to {
opacity: 1;
transform: scaleY(1);
}
}
// Reaction bar animations
@keyframes reactionBarEnter {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes reactionBarEnterLeft {
to {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
}
}
@keyframes reactionBarEnterUp {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes reactionBarEnterRight {
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes reactionBarClose {
to {
opacity: 0;
transform: translateY(-20px) scale(0.9);
}
}
@keyframes reactionBarCloseLeft {
to {
opacity: 0;
transform: translateX(-20px) translateY(-20px) scale(0.9);
}
}
@keyframes reactionBarCloseUp {
to {
opacity: 0;
transform: translateY(20px) scale(0.9);
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUpLeft {
from {
opacity: 0;
transform: translate(10px, 10px);
}
to {
opacity: 1;
transform: translate(0, 0);
}
}
@keyframes fadeOutUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes fadeOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(10px);
}
}
@keyframes fadeOutDown {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes fadeOutDownRight {
from {
opacity: 1;
transform: translate(0, 0);
}
to {
opacity: 0;
transform: translate(10px, 10px);
}
}
@@ -3,13 +3,13 @@
@use "sass:color"; @use "sass:color";
// Unified typing indicator styles (used for both public chat and DMs) // Unified typing indicator styles (used for both public chat and DMs)
.typing-indicator { .typingIndicator {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: $color-dark-primary; color: $color-dark-primary;
.typing-dots { .typingDots {
display: flex; display: flex;
gap: 2px; gap: 2px;
@@ -18,7 +18,7 @@
height: 4px; height: 4px;
border-radius: 50%; border-radius: 50%;
background: $color-dark-primary; background: $color-dark-primary;
animation: typing-dot 1.4s infinite ease-in-out; animation: typingDot 1.4s infinite ease-in-out;
&:nth-child(1) { &:nth-child(1) {
animation-delay: -0.32s; animation-delay: -0.32s;
@@ -30,21 +30,21 @@
} }
} }
.typing-text { .typingText {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
} }
} }
// Online status display (used in DMs when not typing) // Online status display (used in DMs when not typing)
.online-status { .onlineStatus {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 0.8rem; font-size: 0.8rem;
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
.status-dot { .statusDot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
@@ -61,7 +61,7 @@
} }
} }
.status-text { .statusText {
font-weight: 500; font-weight: 500;
font-size: 0.75rem; font-size: 0.75rem;
opacity: 0.8; opacity: 0.8;
@@ -69,7 +69,7 @@
} }
// Online indicator for profile pictures (positioned at bottom right) // Online indicator for profile pictures (positioned at bottom right)
.online-indicator { .onlineIndicator {
position: absolute; position: absolute;
bottom: 0px; bottom: 0px;
right: 0px; right: 0px;
@@ -77,7 +77,7 @@
pointer-events: none; pointer-events: none;
transform: none; transform: none;
.indicator-dot { .indicatorDot {
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
@@ -87,17 +87,15 @@
background: #4caf50; background: #4caf50;
position: relative; position: relative;
transform: none; transform: none;
&.online {
background: #4caf50;
}
} }
} }
// Ensure the icon container allows absolute positioning
mdui-list-item [slot="icon"] {
position: relative;
display: inline-block;
}
// Typing dot animation // Typing dot animation
@keyframes typing-dot { @keyframes typingDot {
0%, 80%, 100% { 0%, 80%, 100% {
transform: scale(0.8); transform: scale(0.8);
opacity: 0.5; opacity: 0.5;
@@ -107,3 +105,4 @@ mdui-list-item [slot="icon"] {
opacity: 1; opacity: 1;
} }
} }
@@ -1,249 +0,0 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.header {
display: flex;
background-color: $color-dark-surface-container;
color: white;
padding: 16px 16px;
justify-content: end;
width: fit-content;
z-index: 1000;
position: absolute;
top: 0;
right: 0;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
.logo {
font-size: 1.8rem;
font-weight: 700;
display: flex;
align-items: center;
}
#logouts {
display: none;
list-style: none;
gap: 10px;
li {
a {
color: white;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
padding: 10px;
border-radius: 10px;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
}
}
}
}
#profile {
display: none;
flex-direction: column;
z-index: 2000;
top: 0;
left: 0;
position: fixed;
height: 100vh;
width: 27%;
background-color: $color-dark-surface-container;
position: relative;
.profileheader {
display: flex;
gap: 200px;
p {
color: white;
}
a {
text-decoration: none;
color: white;
border: solid 2px $color-dark-on-surface-variant;
padding: 5px;
border-radius: 10px;
&:hover {
background-color: rgba(255, 255, 255, 0.241);
}
}
}
}
#chat-list {
display: flex;
flex-direction: column;
flex-grow: 0;
width: 40%;
background-color: $color-dark-surface-container;
height: 100%;
z-index: 1000;
min-height: 0; // allow children to manage their own scrolling
position: relative; // provide positioning context for absolute children
.chat-header-left {
display: flex;
color: white;
font-size: 25px;
background-color: $color-dark-surface-container;
width: 100%;
justify-content: center;
align-items: center;
padding: 16px;
.product-name {
flex-grow: 1;
}
.profile {
font-size: 24px;
display: flex;
justify-content: start;
flex-shrink: 0;
flex-grow: 0;
#closeprofile a {
text-decoration: none;
color: white;
border: solid 2px $color-dark-on-surface-variant;
padding: 5px;
border-radius: 10px;
&:hover {
background-color: rgba(255, 255, 255, 0.241);
}
}
img {
$size: 45px;
display: flex;
border-radius: 50%;
width: $size;
height: $size;
}
}
}
.chat-tabs {
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: hidden;
}
mdui-list {
flex: 1;
min-height: 0; // allow scroll area to size correctly
overflow-y: auto;
padding: 0;
margin: 0;
}
}
mdui-bottom-app-bar {
position: relative;
width: 100%;
padding-left: 16px;
padding-right: 16px;
margin-top: auto;
}
}
// Search container
.search-container {
position: relative;
flex-shrink: 0;
height: 48px + 8px;
}
// ChatHeader component styles
.chat-header-left {
.product-name {
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.profile {
a {
display: flex;
align-items: center;
text-decoration: none;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba($color-dark-primary, 0.4);
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 0 25px rgba($color-dark-primary, 0.5);
border-color: rgba($color-dark-primary, 0.6);
}
}
}
}
}
// Description styling for list items
.list-description {
word-wrap: break-word;
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-box-orient: vertical;
}
@@ -1,59 +0,0 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Reaction styles
.message-reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
margin-left: 10px;
margin-right: 10px;
animation: messageReactionsFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.reaction-button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: none;
border-radius: 16px;
background-color: $color-dark-surface-container;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
font-size: 1px;
min-height: 28px;
animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&.removing {
animation: reactionFadeOut 0.2s ease forwards;
}
&:hover {
background-color: $color-dark-surface-container-high;
transform: scale(1.05);
}
&.reacted {
background-color: $color-dark-primary-container;
border-color: $color-dark-primary;
color: $color-dark-on-primary-container;
&:hover {
background-color: color.adjust($color-dark-primary-container, $lightness: 20%);
}
}
}
.reaction-emoji {
font-size: 17px;
line-height: 1;
}
.reaction-count {
font-size: 12px;
font-weight: 500;
line-height: 1;
}
@@ -1,205 +0,0 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.profile-dialog {
.styled-dialog-content {
align-items: center;
.error-message {
color: $color-dark-error;
font-size: small;
}
.profile-picture-section {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 16px;
.profile-picture {
width: 120px;
height: 120px;
border-radius: 60px;
object-fit: cover;
border: 3px solid $color-dark-outline;
}
.profile-picture-edit-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 60px;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
.username-section {
text-align: center;
.username-with-badge {
gap: 0;
.username-input {
background: none;
border: none;
font-size: 1.5rem;
font-weight: 500;
color: $color-dark-on-surface;
text-align: center;
outline: none;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
cursor: text;
}
}
}
.online-status-section {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.online-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: $color-dark-primary;
&.offline {
background: $color-dark-on-surface-variant;
}
}
.status-text {
font-size: 0.875rem;
color: $color-dark-on-surface;
}
}
.profile-sections {
margin: 16px;
display: flex;
flex-direction: column;
gap: 4px;
width: calc(100% - (16px * 2));
box-sizing: border-box;
.section {
$edge-radius: 24px;
background: $color-dark-surface-container-high;
border-radius: 10px;
padding: 8px 16px;
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
transition: outline 0.1s ease;
outline: 0px solid transparent;
outline-offset: -1px;
.content-container {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
.label {
font-size: small;
color: $color-dark-on-surface-variant;
user-select: none;
}
.value {
color: $color-dark-on-surface;
font-size: medium;
width: 100%;
line-height: 1.4;
font-family: inherit;
cursor: text;
outline: none;
background: transparent;
border: none;
caret-color: $color-dark-primary;
&::placeholder {
color: $color-dark-on-surface-variant;
}
}
}
// First and last section
&:first-child {
border-top-left-radius: $edge-radius;
border-top-right-radius: $edge-radius;
}
&:last-child {
border-bottom-left-radius: $edge-radius;
border-bottom-right-radius: $edge-radius;
}
&.error {
outline: 1px solid $color-dark-error;
.error-message {
margin-top: 4px;
}
}
}
}
}
.profile-dialog-fab {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 1002;
transform: translateY(100px);
transition: transform 0.3s ease;
&.visible {
transform: translateY(0);
}
}
// Admin Actions Section
.admin-actions-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
.admin-actions-header {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px 0;
color: #f44336;
}
.admin-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-buttons mdui-button {
width: 100%;
}
}
@@ -1,22 +1,23 @@
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
.call-window { .callWindow {
$transition: cubic-bezier(0.4, 0, 0.2, 1);
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
user-select: none; user-select: none;
// Base transition for all properties // Add transitions for smooth mode switching
transition: all 0.4s $transition; transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1),
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
// Disable all transitions while dragging for immediate feedback top 0.4s cubic-bezier(0.4, 0, 0.2, 1),
&.dragging { left 0.4s cubic-bezier(0.4, 0, 0.2, 1),
transition: none !important; background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
} backdrop-filter 0.4s cubic-bezier(0.4, 0, 0.2, 1),
border 0.4s cubic-bezier(0.4, 0, 0.2, 1),
border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1);
// Maximized mode (fullscreen) // Maximized mode (fullscreen)
&.maximized { &.maximized {
@@ -33,30 +34,19 @@
border-radius: 0; border-radius: 0;
cursor: default; cursor: default;
&.visible { .callContent {
opacity: 1;
transform: translateY(0);
}
&.hidden {
opacity: 0;
transform: translateY(-100%); // Slide up when ending in fullscreen
pointer-events: none;
}
.call-content {
padding: 32px; padding: 32px;
gap: 24px; gap: 24px;
.video-tiles-grid { .videoTilesGrid {
gap: 24px; gap: 24px;
min-height: 400px; min-height: 400px;
} }
} }
.call-header { .callHeader {
.window-controls { .windowControls {
.window-control-btn { .windowControlBtn {
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
&:hover { &:hover {
@@ -80,28 +70,16 @@
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
cursor: grab; cursor: grab;
&.visible {
opacity: 1;
transform: scale(1);
}
&.hidden {
opacity: 0 !important;
transform: scale(0.7) !important; // Scale down and fade when ending in PiP
pointer-events: none;
transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
&.dragging { &.dragging {
cursor: grabbing; cursor: grabbing;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
} }
.call-header { .callHeader {
padding: 12px; padding: 12px;
min-height: auto; min-height: auto;
.call-header-info { .callHeaderInfo {
.username { .username {
font-size: 14px; font-size: 14px;
} }
@@ -111,8 +89,8 @@
} }
} }
.window-controls { .windowControls {
.window-control-btn { .windowControlBtn {
color: $color-dark-primary; color: $color-dark-primary;
&:hover { &:hover {
@@ -123,52 +101,52 @@
} }
} }
.call-content { .callContent {
padding: 8px; padding: 8px;
gap: 8px; gap: 8px;
.video-tiles-grid { .videoTilesGrid {
gap: 8px; gap: 8px;
min-height: 120px; min-height: 120px;
} }
.video-tile { .videoTile {
.tile-label { .tileLabel {
font-size: 10px; font-size: 10px;
padding: 2px 6px; padding: 2px 6px;
} }
.video-placeholder { .videoPlaceholder {
.placeholder-avatar { .placeholderAvatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
.placeholder-username { .placeholderUsername {
font-size: 12px; font-size: 12px;
} }
} }
} }
.screen-share-tile { .screenShareTile {
.screen-share-video { .screenShareVideo {
object-fit: contain; object-fit: contain;
} }
} }
} }
.call-controls { .callControls {
padding: 8px; padding: 8px;
gap: 8px; gap: 8px;
mdui-button-icon { :global(mdui-button-icon) {
--mdui-comp-icon-button-size: 36px; --mdui-comp-icon-button-size: 36px;
} }
} }
} }
// Dynamic gradients based on call state // Dynamic gradients based on call state
&.gradient-calling { &.gradientCalling {
border-color: rgba(255, 193, 7, 0.5); border-color: rgba(255, 193, 7, 0.5);
&::before { &::before {
@@ -183,12 +161,12 @@
rgba(255, 152, 0, 0.12) 50%, rgba(255, 152, 0, 0.12) 50%,
rgba(255, 193, 7, 0.12) 100%); rgba(255, 193, 7, 0.12) 100%);
border-radius: inherit; border-radius: inherit;
animation: pulse-gradient 2s ease-in-out infinite; animation: pulseGradient 2s ease-in-out infinite;
pointer-events: none; pointer-events: none;
} }
} }
&.gradient-connecting { &.gradientConnecting {
border-color: rgba(33, 150, 243, 0.5); border-color: rgba(33, 150, 243, 0.5);
&::before { &::before {
@@ -203,12 +181,12 @@
rgba(63, 81, 181, 0.12) 50%, rgba(63, 81, 181, 0.12) 50%,
rgba(33, 150, 243, 0.12) 100%); rgba(33, 150, 243, 0.12) 100%);
border-radius: inherit; border-radius: inherit;
animation: connecting-gradient 1.5s ease-in-out infinite; animation: connectingGradient 1.5s ease-in-out infinite;
pointer-events: none; pointer-events: none;
} }
} }
&.gradient-active { &.gradientActive {
border-color: rgba(76, 175, 80, 0.5); border-color: rgba(76, 175, 80, 0.5);
&::before { &::before {
@@ -223,17 +201,17 @@
rgba(56, 142, 60, 0.12) 50%, rgba(56, 142, 60, 0.12) 50%,
rgba(76, 175, 80, 0.12) 100%); rgba(76, 175, 80, 0.12) 100%);
border-radius: inherit; border-radius: inherit;
animation: active-gradient 3s ease-in-out infinite; animation: activeGradient 3s ease-in-out infinite;
pointer-events: none; pointer-events: none;
} }
} }
&.gradient-default { &.gradientDefault {
border-color: rgba(158, 158, 158, 0.3); border-color: rgba(158, 158, 158, 0.3);
} }
} }
.call-header { .callHeader {
padding: 20px; padding: 20px;
border-bottom: 1px solid $color-dark-outline-variant; border-bottom: 1px solid $color-dark-outline-variant;
position: relative; position: relative;
@@ -243,7 +221,7 @@
justify-content: space-between; justify-content: space-between;
flex-shrink: 0; flex-shrink: 0;
.window-controls { .windowControls {
display: flex; display: flex;
gap: 8px; gap: 8px;
position: absolute; position: absolute;
@@ -251,12 +229,12 @@
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
.window-control-btn { .windowControlBtn {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
} }
.call-header-info { .callHeaderInfo {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -278,16 +256,16 @@
font-weight: 500; font-weight: 500;
} }
.encryption-emojis { .encryptionEmojis {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
margin-top: 8px; margin-top: 8px;
.encryption-emoji { .encryptionEmoji {
font-size: 24px; font-size: 24px;
display: inline-block; display: inline-block;
animation: emoji-pulse 2s ease-in-out infinite; animation: emojiPulse 2s ease-in-out infinite;
&:nth-child(1) { animation-delay: 0s; } &:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.2s; } &:nth-child(2) { animation-delay: 0.2s; }
@@ -298,7 +276,7 @@
} }
} }
.call-content { .callContent {
padding: 24px; padding: 24px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -308,55 +286,35 @@
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
// Reduce padding when screen share is active to maximize space &.withScreenShare {
&.with-screen-share {
padding: 12px; padding: 12px;
gap: 16px; gap: 16px;
}
// Default layout (no screen share): tiles in grid .screenShareArea {
.screen-share-area {
display: none;
}
.video-tiles-sidebar {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
flex: 1;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
// Layout with screen share: screen share on left, video tiles on right
&.with-screen-share {
.screen-share-area {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 4; // Give 4x more space than sidebar flex: 4;
min-width: 0; min-width: 0;
max-width: 80vw; // Limit screen share to 80% of viewport width max-width: 80vw;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
// Ensure no padding/margin that could create blank space
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.video-tiles-sidebar { .videoTilesSidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
grid-template-columns: none; grid-template-columns: none;
flex: 1; // Give 1x space (4:1 ratio = 80:20) flex: 1;
min-width: 250px; // Ensure minimum 300px width min-width: 250px;
max-width: 20%; // Cap at 20% of container max-width: 20%;
flex-shrink: 0; flex-shrink: 0;
gap: 16px; gap: 16px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 5px; padding: 5px;
// Custom scrollbar
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
} }
@@ -375,13 +333,24 @@
} }
} }
.video-tile { .videoTile {
min-height: 168px; min-height: 168px;
} }
} }
} }
.video-tile { .screenShareArea {
display: none;
}
.videoTilesSidebar {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
flex: 1;
}
.videoTile {
position: relative; position: relative;
background: rgba($color-dark-surface-variant, 0.5); background: rgba($color-dark-surface-variant, 0.5);
border-radius: 16px; border-radius: 16px;
@@ -397,13 +366,13 @@
transform: scale(1.02); transform: scale(1.02);
} }
.video-element { .videoElement {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.video-placeholder { .videoPlaceholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -413,7 +382,7 @@
height: 100%; height: 100%;
background: rgba($color-dark-surface-variant, 0.6); background: rgba($color-dark-surface-variant, 0.6);
.placeholder-avatar { .placeholderAvatar {
width: 100px; width: 100px;
height: 100px; height: 100px;
border-radius: 50%; border-radius: 50%;
@@ -421,14 +390,14 @@
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
} }
.placeholder-username { .placeholderUsername {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: $color-dark-on-surface; color: $color-dark-on-surface;
} }
} }
.tile-label { .tileLabel {
position: absolute; position: absolute;
bottom: 12px; bottom: 12px;
left: 12px; left: 12px;
@@ -442,26 +411,24 @@
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
} }
&.local-video { &.localVideo {
.video-element { .videoElement {
transform: scaleX(-1); // Mirror local video transform: scaleX(-1);
} }
} }
} }
.screen-share-tile { .screenShareTile {
position: relative; position: relative;
border: 2px solid rgba($color-dark-primary, 0.6); border: 2px solid rgba($color-dark-primary, 0.6);
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: rgba(0, 0, 0, 0.95); background: rgba(0, 0, 0, 0.95);
display: inline-block; display: inline-block;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
// Constrain to available space but size to content
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
.screen-share-video { .screenShareVideo {
width: auto; width: auto;
height: auto; height: auto;
max-width: 100%; max-width: 100%;
@@ -470,7 +437,7 @@
object-fit: contain; object-fit: contain;
} }
.tile-label { .tileLabel {
position: absolute; position: absolute;
bottom: 12px; bottom: 12px;
left: 50%; left: 50%;
@@ -486,9 +453,21 @@
z-index: 1; z-index: 1;
} }
} }
.localScreenShare {
display: flex;
}
.remoteScreenShare {
display: flex;
}
.remoteVideo {
display: block;
}
} }
.call-controls { .callControls {
padding: 20px; padding: 20px;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -498,7 +477,7 @@
z-index: 1; z-index: 1;
flex-shrink: 0; flex-shrink: 0;
mdui-button-icon { :global(mdui-button-icon) {
transition: all 0.2s ease; transition: all 0.2s ease;
&[icon="call_end"] { &[icon="call_end"] {
@@ -525,7 +504,7 @@
} }
} }
.remote-audio { .remoteAudio {
position: fixed; position: fixed;
bottom: 0px; bottom: 0px;
right: 0px; right: 0px;
@@ -536,7 +515,7 @@
} }
// Animations // Animations
@keyframes pulse-gradient { @keyframes pulseGradient {
0%, 100% { 0%, 100% {
opacity: 0.4; opacity: 0.4;
} }
@@ -545,7 +524,7 @@
} }
} }
@keyframes connecting-gradient { @keyframes connectingGradient {
0%, 100% { 0%, 100% {
opacity: 0.3; opacity: 0.3;
} }
@@ -554,7 +533,7 @@
} }
} }
@keyframes active-gradient { @keyframes activeGradient {
0%, 100% { 0%, 100% {
opacity: 0.2; opacity: 0.2;
} }
@@ -563,7 +542,7 @@
} }
} }
@keyframes emoji-pulse { @keyframes emojiPulse {
0%, 100% { 0%, 100% {
transform: scale(1); transform: scale(1);
opacity: 0.8; opacity: 0.8;
@@ -573,3 +552,4 @@
opacity: 1; opacity: 1;
} }
} }
@@ -1,31 +1,31 @@
.change-password-dialog .cpd-container { .cpdContainer {
padding: 16px; padding: 16px;
.cpd-titlebar { .cpdTitlebar {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
margin-bottom: 16px; margin-bottom: 16px;
gap: 10px; gap: 10px;
.cpd-title { .cpdTitle {
font-size: 20px; font-size: 20px;
} }
} }
.cpd-content form { .cpdContent form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
.cpd-logout-all { .cpdLogoutAll {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.cpd-actions { .cpdActions {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
-15
View File
@@ -1,15 +0,0 @@
// Import all component-specific styles
@use "layout";
@use "left-panel";
@use "right-panel";
@use "message";
@use "chat-input";
@use "message-reactions";
@use "context-menu";
@use "settings-dialog";
@use "animations";
@use "callWindow";
@use "profile-dialog";
@use "typing-indicators";
@use "suspension-dialog";
@use "changePasswordDialog";
@@ -1,51 +0,0 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Cropper Dialog Styles
#cropper-dialog {
.cropper-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 500px;
max-width: 600px;
}
.cropper-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid $color-dark-outline;
h3 {
margin: 0;
color: $color-dark-on-surface;
}
}
.cropper-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
background: $color-dark-surface-container;
border-radius: 8px;
overflow: hidden;
#cropper-area {
width: 100%;
height: 100%;
min-height: 400px;
}
}
.cropper-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid $color-dark-outline;
}
}
@@ -2,10 +2,11 @@
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
#chat-interface { .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: 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;
&::before { &::before {
content: ''; content: '';
@@ -22,19 +23,17 @@
z-index: 0; z-index: 0;
} }
.chat-container { .allContainer {
display: flex; display: flex;
flex-direction: row;
width: 100%; width: 100%;
flex-direction: column; height: 100%;
overflow: hidden;
.chatContainer {
display: flex;
width: 100%;
flex-direction: column;
overflow: hidden;
}
} }
} }
// Panel chat styles
// контейнер чата и панели с чатами
.all-container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
@@ -0,0 +1,204 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.chatList {
display: flex;
flex-direction: column;
flex-grow: 0;
width: 40%;
background-color: $color-dark-surface-container;
height: 100%;
z-index: 1000;
min-height: 0; // allow children to manage their own scrolling
position: relative; // provide positioning context for absolute children
overflow: hidden;
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
.chatHeaderLeft {
display: flex;
color: white;
font-size: 25px;
background-color: $color-dark-surface-container;
width: 100%;
justify-content: center;
align-items: center;
padding: 16px;
.productName {
flex-grow: 1;
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.profile {
a {
display: flex;
align-items: center;
text-decoration: none;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba($color-dark-primary, 0.4);
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 0 25px rgba($color-dark-primary, 0.5);
border-color: rgba($color-dark-primary, 0.6);
}
}
}
}
}
.chatTabs {
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;
min-height: 0; // allow scroll area to size correctly
overflow-y: auto;
padding: 0;
margin: 0;
}
}
// Search container
.searchContainer {
position: relative;
flex-shrink: 0;
height: 48px + 8px;
.searchLoading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px;
color: $color-dark-on-surface-variant;
mdui-circular-progress {
--mdui-circular-progress-color: $color-dark-primary;
}
}
.searchEmpty,
.searchHint {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
color: $color-dark-on-surface-variant;
text-align: center;
}
// Custom styling for search result images
mdui-list-item {
.searchResultContainer {
display: flex;
.searchResult {
display: flex;
flex-direction: row;
align-items: center;
margin: 8px 12px;
box-sizing: border-box;
.searchResultIcon {
position: relative;
width: 40px;
height: 40px;
display: flex;
margin-right: 16px;
.searchResultIconImg {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
}
.searchResultBody {
display: flex;
flex-direction: column;
.searchResultHeadline {
display: flex;
align-items: center;
gap: 6px;
}
.searchResultDescription {
display: flex;
align-items: center;
}
}
}
}
}
}
mdui-bottom-app-bar {
position: relative;
width: 100%;
padding-left: 16px;
padding-right: 16px;
margin-top: auto;
}
}
// Description styling for list items
.listDescription {
word-wrap: break-word;
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-box-orient: vertical;
}
@@ -0,0 +1,209 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Override StyledDialog's content
.profileDialogContent {
align-items: center;
}
.errorMessage {
color: $color-dark-error;
font-size: small;
}
.profilePictureSection {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 16px;
}
.profilePicture {
width: 120px;
height: 120px;
border-radius: 60px;
object-fit: cover;
border: 3px solid $color-dark-outline;
}
.profilePictureEditOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 60px;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.usernameSection {
text-align: center;
.usernameWithBadge {
gap: 0;
.usernameInput {
background: none;
border: none;
font-size: 1.5rem;
font-weight: 500;
color: $color-dark-on-surface;
text-align: center;
outline: none;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
cursor: text;
}
}
}
.onlineStatusSection {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.onlineIndicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: $color-dark-primary;
&.offline {
background: $color-dark-on-surface-variant;
}
}
.statusText {
font-size: 0.875rem;
color: $color-dark-on-surface;
}
}
.profileSections {
margin: 16px;
display: flex;
flex-direction: column;
gap: 4px;
width: calc(100% - (16px * 2));
box-sizing: border-box;
}
.section {
$edge-radius: 24px;
background: $color-dark-surface-container-high;
border-radius: 10px;
padding: 8px 16px;
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
transition: outline 0.1s ease;
outline: 0px solid transparent;
outline-offset: -1px;
.contentContainer {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
.label {
font-size: small;
color: $color-dark-on-surface-variant;
user-select: none;
}
.value {
color: $color-dark-on-surface;
font-size: medium;
width: 100%;
line-height: 1.4;
font-family: inherit;
cursor: text;
outline: none;
background: transparent;
border: none;
caret-color: $color-dark-primary;
&::placeholder {
color: $color-dark-on-surface-variant;
}
}
}
// First and last section
&:first-child {
border-top-left-radius: $edge-radius;
border-top-right-radius: $edge-radius;
}
&:last-child {
border-bottom-left-radius: $edge-radius;
border-bottom-right-radius: $edge-radius;
}
&.error {
outline: 1px solid $color-dark-error;
.errorMessage {
margin-top: 4px;
}
}
}
.profileDialogFab {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 1002;
transform: translateY(100px);
transition: transform 0.3s ease;
&.visible {
transform: translateY(0);
}
}
// Admin Actions Section
.adminActionsSection {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
.adminActionsHeader {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px 0;
color: #f44336;
}
.adminButtons {
display: flex;
flex-direction: column;
gap: 12px;
:global(mdui-button) {
width: 100%;
}
}
.verifySection {
display: block;
}
@@ -2,20 +2,20 @@
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
.chat-wrapper { .chatWrapper {
flex-grow: 1; flex-grow: 1;
height: 100%; height: 100%;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
.chat-main { .chatMain {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative; position: relative;
overflow-y: auto; overflow-y: auto;
.chat-header { .chatHeader {
padding: 16px; padding: 16px;
margin: 10px 10px 0 10px; margin: 10px 10px 0 10px;
background: rgba($color-dark-surface-container, 0.7); background: rgba($color-dark-surface-container, 0.7);
@@ -29,7 +29,7 @@
top: 10px; top: 10px;
z-index: 5; z-index: 5;
.chat-header-avatar { .chatHeaderAvatar {
width: 45px; width: 45px;
height: 45px; height: 45px;
border-radius: 20%; border-radius: 20%;
@@ -37,13 +37,13 @@
margin-right: 1rem; margin-right: 1rem;
} }
.chat-header-info { .chatHeaderInfo {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex: 1; flex: 1;
.info-chat { .infoChat {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -78,7 +78,7 @@
} }
} }
.chat-messages { .chatMessages {
flex: 1; flex: 1;
padding: 10px 20px; padding: 10px 20px;
position: relative; position: relative;
@@ -97,45 +97,44 @@
border-radius: 20px; border-radius: 20px;
} }
} }
}
.file-overlay { .fileOverlay {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
backdrop-filter: blur(20px);
pointer-events: none;
.fileOverlayWrapper {
border-radius: 30px;
outline: 3px dashed $color-dark-primary;
outline-offset: -20px;
height: 100%; height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5); .fileOverlayInner {
z-index: 100;
backdrop-filter: blur(20px);
.file-overlay-wrapper {
border-radius: 30px;
outline: 3px dashed $color-dark-primary;
outline-offset: -20px;
height: 100%;
width: 100%;
display: flex; display: flex;
gap: 12px;
align-items: center; align-items: center;
justify-content: center; padding: 12px 16px;
background: rgba(18, 18, 18, 0.8);
border: 1px solid $color-dark-surface-container-high;
border-radius: 12px;
color: $color-dark-on-surface;
.file-overlay-inner { mdui-icon {
display: flex; color: $color-dark-primary;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: rgba(18, 18, 18, 0.8);
border: 1px solid $color-dark-surface-container-high;
border-radius: 12px;
color: $color-dark-on-surface;
mdui-icon {
color: $color-dark-primary;
}
} }
} }
} }
} }
} }
@@ -1,18 +1,15 @@
@use "../../../css/colors" as *; @use "../../../css/colors" as *;
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "../../../core/components/css/styled-dialog" as *;
@use "sass:color"; @use "sass:color";
// Settings styles // Settings styles - override StyledDialog's backdrop and dialog
.styled-dialog-backdrop.settings-dialog { .settingsDialog {
.styled-dialog { width: calc(100vw - 60px) !important;
width: calc(100vw - $dialog-padding * 2); height: calc(100vh - 60px) !important;
height: calc(100vh - $dialog-padding * 2); max-width: none !important;
max-width: none; max-height: none !important;
max-height: none;
}
#settings-dialog-inner { .settingsDialogInner {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
@@ -25,16 +22,20 @@
gap: 10px; gap: 10px;
margin-bottom: 16px; margin-bottom: 16px;
flex-shrink: 0; flex-shrink: 0;
.title {
display: block;
}
} }
#settings-menu { .settingsMenu {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 16px; gap: 16px;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
mdui-list { :global(mdui-list) {
max-width: 280px; max-width: 280px;
padding-right: 16px; padding-right: 16px;
overflow-y: auto; overflow-y: auto;
@@ -47,7 +48,7 @@
position: relative; position: relative;
min-width: 0; min-width: 0;
.settings-panel { .settingsPanel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@@ -67,19 +68,19 @@
position: relative; position: relative;
} }
h3 { :global(h3) {
margin: 0 0 16px 0; margin: 0 0 16px 0;
color: $color-dark-on-surface; color: $color-dark-on-surface;
} }
mdui-text-field, :global(mdui-text-field),
mdui-select, :global(mdui-select),
mdui-switch, :global(mdui-switch),
mdui-button { :global(mdui-button) {
margin-bottom: 8px; margin-bottom: 8px;
} }
mdui-switch { :global(mdui-switch) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -91,14 +92,18 @@
} }
} }
p { :global(p) {
margin: 8px 0; margin: 8px 0;
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
} }
mdui-linear-progress { :global(mdui-linear-progress) {
margin: 16px 0; margin: 16px 0;
} }
.productName {
display: inline;
}
} }
} }
} }
@@ -3,7 +3,7 @@
@use "sass:color"; @use "sass:color";
// Suspension Dialog Content Styles // Suspension Dialog Content Styles
.suspension-dialog-content { .suspensionDialogContent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -11,20 +11,20 @@
width: 100%; width: 100%;
padding: 24px; padding: 24px;
.suspension-icon-section { .suspensionIconSection {
margin-bottom: 24px; margin-bottom: 24px;
.suspension-icon { .suspensionIcon {
font-size: 80px; font-size: 80px;
color: #f44336; color: #f44336;
display: block; display: block;
} }
} }
.suspension-text { .suspensionText {
max-width: 400px; max-width: 400px;
.suspension-headline { .suspensionHeadline {
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 600;
margin: 0 0 16px 0; margin: 0 0 16px 0;
@@ -32,14 +32,14 @@
line-height: 1.2; line-height: 1.2;
} }
.suspension-body { .suspensionBody {
font-size: 16px; font-size: 16px;
margin: 0 0 20px 0; margin: 0 0 20px 0;
color: $color-dark-on-surface; color: $color-dark-on-surface;
line-height: 1.5; line-height: 1.5;
} }
.suspension-reason { .suspensionReason {
text-align: left; text-align: left;
strong { strong {
@@ -47,7 +47,7 @@
font-weight: 600; font-weight: 600;
} }
.suspension-reason-text { .suspensionReasonText {
background: $color-dark-surface-container-high; background: $color-dark-surface-container-high;
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
@@ -58,7 +58,7 @@
} }
} }
.suspension-secondary { .suspensionSecondary {
font-size: 14px; font-size: 14px;
margin: 20px 0 0 0; margin: 20px 0 0 0;
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
+34 -20
View File
@@ -372,12 +372,19 @@ export const useAppState = create<AppState>((set, get) => ({
}, },
// Panel management // Panel management
setActivePanel: (panel: MessagePanel | null) => set((state) => ({ setActivePanel: (panel: MessagePanel | null) => {
chat: { const state = get();
...state.chat, // Deactivate the current panel before switching
activePanel: panel if (state.chat.activePanel && state.chat.activePanel !== panel) {
state.chat.activePanel.deactivate();
} }
})), return set((state) => ({
chat: {
...state.chat,
activePanel: panel
}
}));
},
// Stash a panel to be applied after switch-out animation ends // Stash a panel to be applied after switch-out animation ends
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({ setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
chat: { chat: {
@@ -386,22 +393,29 @@ export const useAppState = create<AppState>((set, get) => ({
} }
})), })),
// Apply pending panel atomically and update related fields // Apply pending panel atomically and update related fields
applyPendingPanel: () => set((state) => ({ applyPendingPanel: () => {
chat: { const state = get();
...state.chat, // Deactivate the current panel before switching
activePanel: state.chat.pendingPanel || state.chat.activePanel, if (state.chat.activePanel) {
// when switching to public chat, keep reference if type matches state.chat.activePanel.deactivate();
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
? (state.chat.pendingPanel as PublicChatPanel)
: state.chat.publicChatPanel,
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
? (state.chat.pendingPanel as DMPanel)
: state.chat.dmPanel,
// update currentChat from panel title if available
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
pendingPanel: null
} }
})), return set((state) => ({
chat: {
...state.chat,
activePanel: state.chat.pendingPanel || state.chat.activePanel,
// when switching to public chat, keep reference if type matches
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
? (state.chat.pendingPanel as PublicChatPanel)
: state.chat.publicChatPanel,
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
? (state.chat.pendingPanel as DMPanel)
: state.chat.dmPanel,
// update currentChat from panel title if available
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
pendingPanel: null
}
}));
},
switchToPublicChat: async (chatName: string) => { switchToPublicChat: async (chatName: string) => {
const { user, chat } = get(); const { user, chat } = get();
+3 -3
View File
@@ -1,12 +1,12 @@
import { LeftPanel } from "./left/LeftPanel"; import { LeftPanel } from "./left/LeftPanel";
import { RightPanel } from "./right/RightPanel"; import { RightPanel } from "./right/RightPanel";
import "@/pages/chat/css/chat.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { CallWindow } from "./right/calls/CallWindow"; import { CallWindow } from "./right/calls/CallWindow";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi"; import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi";
import styles from "@/pages/chat/css/layout.module.scss";
export default function ChatPage() { export default function ChatPage() {
const { navigate: navigateDownloadApp } = useDownloadAppScreen(); const { navigate: navigateDownloadApp } = useDownloadAppScreen();
@@ -69,8 +69,8 @@ export default function ChatPage() {
if (navigateDownloadApp) return navigateDownloadApp; if (navigateDownloadApp) return navigateDownloadApp;
return ( return (
<div id="chat-interface"> <div className={styles.chatInterface}>
<div className="all-container"> <div className={styles.allContainer}>
<LeftPanel /> <LeftPanel />
<RightPanel /> <RightPanel />
</div> </div>
+23 -22
View File
@@ -13,6 +13,7 @@ import { OnlineStatus } from "./right/OnlineStatus";
import { Input } from "@/core/components/Input"; import { Input } from "@/core/components/Input";
import { StyledDialog } from "@/core/components/StyledDialog"; import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialButton, MaterialFab, MaterialIcon } from "@/utils/material"; import { MaterialButton, MaterialFab, MaterialIcon } from "@/utils/material";
import styles from "@/pages/chat/css/profile-dialog.module.scss";
interface SectionProps { interface SectionProps {
type: string; type: string;
@@ -36,14 +37,14 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
text={value || ""} text={value || ""}
onTextChange={onChange} onTextChange={onChange}
placeholder={placeholder} placeholder={placeholder}
className="value" className={styles.value}
rows={1} rows={1}
readOnly={readOnly} /> readOnly={readOnly} />
); );
} else { } else {
valueComponent = ( valueComponent = (
<input <input
className="value" className={styles.value}
type="text" type="text"
value={value} value={value}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
@@ -51,17 +52,17 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
); );
} }
} else { } else {
valueComponent = <span className="value">{value}</span> valueComponent = <span className={styles.value}>{value}</span>
} }
return ( return (
<div className={`section ${type} ${error ? 'error' : ''}`}> <div className={`${styles.section} ${type} ${error ? styles.error : ''}`}>
<MaterialIcon name={icon} /> <MaterialIcon name={icon} />
<div className="content-container"> <div className={styles.contentContainer}>
<label className="label">{label}</label> <label className={styles.label}>{label}</label>
{valueComponent} {valueComponent}
{error && ( {error && (
<div className="error-message">{error}</div> <div className={styles.errorMessage}>{error}</div>
)} )}
</div> </div>
</div> </div>
@@ -440,20 +441,20 @@ export function ProfileDialog() {
} }
}} }}
onBackdropClick={handleClose} onBackdropClick={handleClose}
className="profile-dialog" contentClassName={styles.profileDialogContent}
afterChildren={ afterChildren={
currentData.isOwnProfile && ( currentData.isOwnProfile && (
<MaterialFab <MaterialFab
icon="check" icon="check"
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`} className={`${styles.profileDialogFab} ${fabVisible ? styles.visible : ""}`}
onClick={handleSave} onClick={handleSave}
disabled={isSaving} /> disabled={isSaving} />
) )
} }
> >
<div className="profile-picture-section"> <div className={styles.profilePictureSection}>
<img <img
className="profile-picture" className={styles.profilePicture}
src={currentData.profilePicture || defaultAvatar} src={currentData.profilePicture || defaultAvatar}
alt="Profile Picture" alt="Profile Picture"
onError={(e) => { onError={(e) => {
@@ -463,7 +464,7 @@ export function ProfileDialog() {
{currentData.isOwnProfile && ( {currentData.isOwnProfile && (
<div <div
className="profile-picture-edit-overlay" className={styles.profilePictureEditOverlay}
onClick={handleProfilePictureClick} onClick={handleProfilePictureClick}
> >
<MaterialIcon name="camera_alt--filled" /> <MaterialIcon name="camera_alt--filled" />
@@ -471,11 +472,11 @@ export function ProfileDialog() {
)} )}
</div> </div>
<div className={`username-section ${errors.display_name ? 'error' : ''}`}> <div className={`${styles.usernameSection} ${errors.display_name ? styles.error : ''}`}>
<div className="username-with-badge"> <div className={styles.usernameWithBadge}>
<Input <Input
autoresizing={true} autoresizing={true}
className="username-input" className={styles.usernameInput}
type="text" type="text"
value={currentData.display_name} value={currentData.display_name}
onChange={handleDisplayNameChange} onChange={handleDisplayNameChange}
@@ -488,21 +489,21 @@ export function ProfileDialog() {
size="large" /> size="large" />
</div> </div>
{errors.display_name && ( {errors.display_name && (
<div className="error-message">{errors.display_name}</div> <div className={styles.errorMessage}>{errors.display_name}</div>
)} )}
</div> </div>
{(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && ( {(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && (
<div className="online-status-section"> <div className={styles.onlineStatusSection}>
<OnlineStatus userId={currentData.userId || user.currentUser!.id} /> <OnlineStatus userId={currentData.userId || user.currentUser!.id} />
</div> </div>
)} )}
{/* Admin Actions Section - Hide for deleted users */} {/* Admin Actions Section - Hide for deleted users */}
{!currentData.isOwnProfile && user.currentUser?.id === 1 && !currentData.deleted && ( {!currentData.isOwnProfile && user.currentUser?.id === 1 && !currentData.deleted && (
<div className="admin-actions-section"> <div className={styles.adminActionsSection}>
<h3 className="admin-actions-header">Admin Actions</h3> <h3 className={styles.adminActionsHeader}>Admin Actions</h3>
<div className="admin-buttons"> <div className={styles.adminButtons}>
<MaterialButton <MaterialButton
variant="filled" variant="filled"
color="error" color="error"
@@ -532,7 +533,7 @@ export function ProfileDialog() {
{/* Verify button for non-admin owner */} {/* Verify button for non-admin owner */}
{!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && ( {!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && (
<div className="verify-section"> <div className={styles.verifySection}>
<VerifyButton <VerifyButton
userId={currentData.userId} userId={currentData.userId}
verified={currentData.verified || false} verified={currentData.verified || false}
@@ -545,7 +546,7 @@ export function ProfileDialog() {
{/* Hide profile sections for deleted users */} {/* Hide profile sections for deleted users */}
{!currentData.deleted && ( {!currentData.deleted && (
<div className="profile-sections"> <div className={styles.profileSections}>
<Section <Section
type="username" type="username"
error={errors.username} error={errors.username}
@@ -1,5 +1,6 @@
import { StyledDialog } from "@/core/components/StyledDialog"; import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialIcon } from "@/utils/material"; import { MaterialIcon } from "@/utils/material";
import styles from "@/pages/chat/css/suspension-dialog.module.scss";
interface SuspensionDialogProps { interface SuspensionDialogProps {
reason: string; reason: string;
@@ -12,26 +13,26 @@ export function SuspensionDialog({ reason, open, onOpenChange }: SuspensionDialo
<StyledDialog <StyledDialog
open={open} open={open}
onOpenChange={onOpenChange}> onOpenChange={onOpenChange}>
<div className="suspension-dialog-content"> <div className={styles.suspensionDialogContent}>
<div className="suspension-icon-section"> <div className={styles.suspensionIconSection}>
<MaterialIcon name="block--filled" className="suspension-icon" /> <MaterialIcon name="block--filled" className={styles.suspensionIcon} />
</div> </div>
<div className="suspension-text"> <div className={styles.suspensionText}>
<h2 className="suspension-headline">Аккаунт заблокирован</h2> <h2 className={styles.suspensionHeadline}>Аккаунт заблокирован</h2>
<p className="suspension-body"> <p className={styles.suspensionBody}>
Ваш аккаунт был заблокирован за нарушение правил сообщества. Ваш аккаунт был заблокирован за нарушение правил сообщества.
Вы не можете отправлять сообщения или взаимодействовать с другими пользователями. Вы не можете отправлять сообщения или взаимодействовать с другими пользователями.
</p> </p>
{reason && reason !== "No reason provided" && ( {reason && reason !== "No reason provided" && (
<div className="suspension-reason"> <div className={styles.suspensionReason}>
<strong>Причина блокировки:</strong> <strong>Причина блокировки:</strong>
<div className="suspension-reason-text"> <div className={styles.suspensionReasonText}>
{reason} {reason}
</div> </div>
</div> </div>
)} )}
<p className="suspension-secondary"> <p className={styles.suspensionSecondary}>
Если вы считаете, что блокировка была применена по ошибке, Если вы считаете, что блокировка была применена по ошибке,
<a href="https://t.me/denis0001_dev" target="_blank" rel="noopener noreferrer">обратитесь к администратору</a> для рассмотрения вашего случая. <a href="https://t.me/denis0001_dev" target="_blank" rel="noopener noreferrer">обратитесь к администратору</a> для рассмотрения вашего случая.
</p> </p>
@@ -4,6 +4,7 @@ import defaultAvatar from "@/images/default-avatar.png";
import { useState } from "react"; 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";
export function ChatHeader() { export function ChatHeader() {
const { profileData } = useProfile(); const { profileData } = useProfile();
@@ -25,9 +26,9 @@ export function ChatHeader() {
return ( return (
<> <>
<header className="chat-header-left"> <header className={styles.chatHeaderLeft}>
<div className="product-name">{PRODUCT_NAME}</div> <div className={styles.productName}>{PRODUCT_NAME}</div>
<div className="profile"> <div className={styles.profile}>
<a href="#" id="profile-open" onClick={handleProfileClick}> <a href="#" id="profile-open" onClick={handleProfileClick}>
<img <img
src={profilePictureUrl} src={profilePictureUrl}
+2 -1
View File
@@ -1,12 +1,13 @@
import { useAppState, type ChatTabs } from "@/pages/chat/state"; import { useAppState, type ChatTabs } from "@/pages/chat/state";
import { UnifiedChatsList } from "./UnifiedChatsList"; import { UnifiedChatsList } from "./UnifiedChatsList";
import { MaterialTab, MaterialTabPanel, MaterialTabs } from "@/utils/material"; import { MaterialTab, MaterialTabPanel, MaterialTabs } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
export function ChatTabs() { export function ChatTabs() {
const { chat, setActiveTab } = useAppState(); const { chat, setActiveTab } = useAppState();
return ( return (
<div className="chat-tabs"> <div className={styles.chatTabs}>
<MaterialTabs <MaterialTabs
value={chat.activeTab} value={chat.activeTab}
full-width full-width
@@ -5,6 +5,7 @@ import { UsernameSearch } from "./UsernameSearch";
import { ChatTabs } from "./ChatTabs"; import { ChatTabs } from "./ChatTabs";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import { MaterialBottomAppBar, MaterialFab, MaterialIconButton } from "@/utils/material"; import { MaterialBottomAppBar, MaterialFab, MaterialIconButton } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
function BottomAppBar() { function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false); const [settingsOpen, onSettingsOpenChange] = useState(false);
@@ -30,9 +31,9 @@ function BottomAppBar() {
export function LeftPanel() { export function LeftPanel() {
return ( return (
<div className="chat-list" id="chat-list"> <div className={styles.chatList} id="chat-list">
<ChatHeader /> <ChatHeader />
<div className="search-container"> <div className={styles.searchContainer}>
<UsernameSearch /> <UsernameSearch />
</div> </div>
<ChatTabs /> <ChatTabs />
@@ -11,6 +11,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator"; import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { MaterialBadge, MaterialCircularProgress, MaterialList, MaterialListItem } from "@/utils/material"; import { MaterialBadge, MaterialCircularProgress, MaterialList, MaterialListItem } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
interface PublicChat { interface PublicChat {
id: string; id: string;
@@ -239,7 +240,7 @@ export function UnifiedChatsList() {
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
{formatPublicChatMessage(chat.id) && ( {formatPublicChatMessage(chat.id) && (
<span slot="description" className="list-description"> <span slot="description" className={styles.listDescription}>
{formatPublicChatMessage(chat.id)} {formatPublicChatMessage(chat.id)}
</span> </span>
)} )}
@@ -272,7 +273,7 @@ export function UnifiedChatsList() {
size="small" size="small"
/> />
</div> </div>
<span slot="description" className="list-description"> <span slot="description" className={styles.listDescription}>
{chat.lastMessage || "Нет сообщений"} {chat.lastMessage || "Нет сообщений"}
</span> </span>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}> <div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
@@ -1,13 +1,15 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi"; import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import type { User } from "@/core/types"; import type { User } from "@/core/types";
import { onlineStatusManager } from "@/core/onlineStatusManager"; import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator"; import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
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 } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
interface SearchUser extends User { interface SearchUser extends User {
publicKey?: string | null; publicKey?: string | null;
@@ -15,12 +17,14 @@ interface SearchUser extends User {
} }
export function UsernameSearch() { export function UsernameSearch() {
const { user, switchToDM } = useAppState(); const { user, switchToDM, chat } = useAppState();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchUser[]>([]); const [searchResults, setSearchResults] = useState<SearchUser[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [debounceTimeout, setDebounceTimeout] = useState<NodeJS.Timeout | null>(null); const [debounceTimeout, setDebounceTimeout] = useState<NodeJS.Timeout | null>(null);
const switchingToUserIdRef = useRef<number | null>(null);
const previousSearchResultIdsRef = useRef<Set<number>>(new Set());
// Debounced search // Debounced search
useEffect(() => { useEffect(() => {
@@ -58,18 +62,45 @@ export function UsernameSearch() {
// Subscribe to online status for all search results // Subscribe to online status for all search results
useEffect(() => { useEffect(() => {
// Subscribe to all search results const activeDmUserId = chat.activeDm?.userId;
const switchingToUserId = switchingToUserIdRef.current;
const currentSearchResultIds = new Set(searchResults.map(u => u.id));
const previousSearchResultIds = new Set(previousSearchResultIdsRef.current);
// Unsubscribe from users that were in previous results but not in current results
// (unless they're the active DM or we're switching to them)
previousSearchResultIds.forEach(userId => {
if (!currentSearchResultIds.has(userId) &&
userId !== activeDmUserId &&
userId !== switchingToUserId) {
onlineStatusManager.unsubscribe(userId);
}
});
// Subscribe to all current search results
searchResults.forEach(searchUser => { searchResults.forEach(searchUser => {
onlineStatusManager.subscribe(searchUser.id); onlineStatusManager.subscribe(searchUser.id);
}); });
// Cleanup function to unsubscribe from all users // Update previous results for next effect run
previousSearchResultIdsRef.current = currentSearchResultIds;
// Cleanup function - don't unsubscribe here as normal transitions are handled in effect body
// This only runs when component unmounts or when transitioning to empty results
return () => { return () => {
searchResults.forEach(searchUser => { // Note: Normal search result transitions are handled above in the effect body
onlineStatusManager.unsubscribe(searchUser.id); // by comparing previous vs current. This cleanup only runs when the component
}); // unmounts or when the dependency changes, but we've already handled
// unsubscription in the effect body above, so this is mostly a no-op for normal transitions.
// Clear the ref if the user is now the active DM (state has updated)
const finalSwitchingToUserId = switchingToUserIdRef.current;
const finalActiveDmUserId = chat.activeDm?.userId;
if (finalSwitchingToUserId && finalSwitchingToUserId === finalActiveDmUserId) {
switchingToUserIdRef.current = null;
}
}; };
}, [searchResults]); }, [searchResults, chat.activeDm?.userId]);
async function handleUserClick(searchUser: SearchUser) { async function handleUserClick(searchUser: SearchUser) {
@@ -84,6 +115,8 @@ export function UsernameSearch() {
} }
if (publicKey) { if (publicKey) {
// Store the userId we're switching to so cleanup doesn't unsubscribe
switchingToUserIdRef.current = searchUser.id;
switchToDM({ switchToDM({
userId: searchUser.id, userId: searchUser.id,
username: searchUser.username, username: searchUser.username,
@@ -134,14 +167,14 @@ export function UsernameSearch() {
) : "search--outlined"} ) : "search--outlined"}
> >
{isSearching && ( {isSearching && (
<div className="search-loading"> <div className={styles.searchLoading}>
<MaterialCircularProgress /> <MaterialCircularProgress />
<span>Поиск...</span> <span>Поиск...</span>
</div> </div>
)} )}
{!isSearching && searchQuery.length >= 2 && searchResults.length === 0 && ( {!isSearching && searchQuery.length >= 2 && searchResults.length === 0 && (
<div className="search-empty"> <div className={styles.searchEmpty}>
<span>Пользователи не найдены</span> <span>Пользователи не найдены</span>
</div> </div>
)} )}
@@ -155,30 +188,33 @@ export function UsernameSearch() {
onClick={() => handleUserClick(searchUser)} onClick={() => handleUserClick(searchUser)}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
<div slot="headline" className="search-result-headline"> <div slot="custom" className={styles.searchResultContainer}>
{searchUser.username} <div className={styles.searchResult}>
<StatusBadge <div className={styles.searchResultIcon}>
verified={searchUser.verified || false} <img
userId={searchUser.id} src={searchUser.profile_picture || defaultAvatar}
size="small" alt={searchUser.username}
/> className={styles.searchResultIconImg}
</div> onError={(e) => {
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}> e.target.src = defaultAvatar;
<img }}
src={searchUser.profile_picture || defaultAvatar} />
alt={searchUser.username} <OnlineIndicator userId={searchUser.id} />
style={{ </div>
width: "40px", <div className={styles.searchResultBody}>
height: "40px", <div className={styles.searchResultHeadline}>
borderRadius: "50%", {searchUser.username}
objectFit: "cover", <StatusBadge
display: "block" verified={searchUser.verified || false}
}} userId={searchUser.id}
onError={(e) => { size="small"
e.target.src = defaultAvatar; />
}} </div>
/> <div className={styles.searchResultDescription}>
<OnlineIndicator userId={searchUser.id} /> <OnlineStatus userId={searchUser.id} />
</div>
</div>
</div>
</div> </div>
</MaterialListItem> </MaterialListItem>
))} ))}
@@ -186,7 +222,7 @@ export function UsernameSearch() {
)} )}
{!isSearching && searchQuery.length < 2 && ( {!isSearching && searchQuery.length < 2 && (
<div className="search-hint"> <div className={styles.searchHint}>
<span>Введите минимум 2 символа для поиска</span> <span>Введите минимум 2 символа для поиска</span>
</div> </div>
)} )}
@@ -1,22 +0,0 @@
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import "./css/cropper-dialog.scss";
export function CropperDialog() {
return (
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<MaterialIconButton icon="close" id="cropper-close" />
</div>
<div className="cropper-container">
<div id="cropper-area"></div>
</div>
<div className="cropper-actions">
<MaterialButton id="crop-cancel" variant="outlined">Отмена</MaterialButton>
<MaterialButton id="crop-save">Сохранить</MaterialButton>
</div>
</div>
</mdui-dialog>
);
}
@@ -1,193 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "@/core/types";
import { MaterialButton } from "@/utils/material";
interface ImageCropperProps {
onCrop: (croppedImageData: string) => void;
onCancel: () => void;
imageFile: File | null;
}
export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [src, setSrc] = useState<string | undefined>(undefined);
const [isLoaded, setIsLoaded] = useState(false);
const [cropArea, setCropArea] = useState<Rect>({ x: 0, y: 0, width: 200, height: 200 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
useEffect(() => {
if (imageFile) {
const reader = new FileReader();
function handleImageLoad() {
setIsLoaded(true);
// Initialize crop area to center of image
const img = imageRef.current;
if (img) {
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
setCropArea({
x: (img.naturalWidth - size) / 2,
y: (img.naturalHeight - size) / 2,
width: size,
height: size
});
}
}
function handleReaderLoad() {
if (imageRef.current) {
setSrc(reader.result as string);
imageRef.current.addEventListener("load", handleImageLoad);
}
}
reader.addEventListener("load", handleReaderLoad);
reader.readAsDataURL(imageFile);
return () => {
reader.abort();
reader.removeEventListener("load", handleReaderLoad);
imageRef.current?.removeEventListener("load", handleImageLoad);
}
}
}, [imageFile]);
function handleMouseDown(e: React.MouseEvent) {
if (!isLoaded) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if click is within crop area
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
setIsDragging(true);
setDragStart({ x: x - cropArea.x, y: y - cropArea.y });
}
};
function handleMouseMove(e: React.MouseEvent) {
const rect = canvasRef.current?.getBoundingClientRect();
if (isDragging && isLoaded && rect && imageRef.current) {
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const newX = Math.max(
0,
Math.min(
x - dragStart.x,
imageRef.current.naturalWidth - cropArea.width
)
);
const newY = Math.max(
0,
Math.min(
y - dragStart.y,
imageRef.current.naturalHeight - cropArea.height
)
);
setCropArea(prev => ({ ...prev, x: newX, y: newY }));
}
};
function handleMouseUp() {
setIsDragging(false);
};
function handleCrop() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to crop area
canvas.width = cropArea.width;
canvas.height = cropArea.height;
// Draw cropped portion
ctx.drawImage(
imageRef.current,
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
0, 0, cropArea.width, cropArea.height
);
// Convert to data URL
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
onCrop(croppedImageData);
};
function drawCropArea() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw image
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
// Draw crop overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
// Draw crop border
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
};
useEffect(() => {
drawCropArea();
}, [cropArea, isLoaded]);
if (!imageFile) return null;
return (
<div className="cropper-container">
<canvas
ref={canvasRef}
width={400}
height={400}
style={{
cursor: isDragging ? 'grabbing' : 'grab',
border: '1px solid #ccc',
maxWidth: '100%',
height: 'auto'
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
<img
ref={imageRef}
src={src}
style={{ display: 'none' }}
alt="Crop source"
/>
<div className="cropper-actions">
<MaterialButton onClick={handleCrop} disabled={!isLoaded}>
Обрезать
</MaterialButton>
<MaterialButton variant="outlined" onClick={onCancel}>
Отмена
</MaterialButton>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import type { DialogProps } from "@/core/types";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { changePassword } from "@/core/api/securityApi"; import { changePassword } from "@/core/api/securityApi";
import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material";
import styles from "@/pages/chat/css/changePasswordDialog.module.scss";
export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) { export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) {
const { user } = useAppState(); const { user } = useAppState();
@@ -16,12 +17,12 @@ export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogPro
return ( return (
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="change-password-dialog"> <StyledDialog open={isOpen} onOpenChange={onOpenChange} className="change-password-dialog">
<div className="cpd-container"> <div className={styles.cpdContainer}>
<div className="cpd-titlebar"> <div className={styles.cpdTitlebar}>
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton> <MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className="cpd-title">Изменить пароль</div> <div className={styles.cpdTitle}>Изменить пароль</div>
</div> </div>
<div className="cpd-content"> <div className={styles.cpdContent}>
<form onSubmit={async (e) => { <form onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
if (!user.authToken || !user.currentUser?.username) return; if (!user.authToken || !user.currentUser?.username) return;
@@ -64,14 +65,14 @@ export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogPro
variant="outlined" variant="outlined"
toggle-password toggle-password
required /> required />
<div className="cpd-logout-all"> <div className={styles.cpdLogoutAll}>
<MaterialSwitch <MaterialSwitch
name="cpd-logout-all" name="cpd-logout-all"
checked={logoutAll} checked={logoutAll}
onInput={(e) => setLogoutAll(e.target.checked)} /> onInput={(e) => setLogoutAll(e.target.checked)} />
<label htmlFor="cpd-logout-all">Выйти на всех устройствах (кроме текущего)</label> <label htmlFor="cpd-logout-all">Выйти на всех устройствах (кроме текущего)</label>
</div> </div>
<div className="cpd-actions"> <div className={styles.cpdActions}>
<MaterialButton type="submit" disabled={busy}>Сохранить</MaterialButton> <MaterialButton type="submit" disabled={busy}>Сохранить</MaterialButton>
</div> </div>
</form> </form>
@@ -10,6 +10,7 @@ import ChangePasswordDialog from "./ChangePasswordDialog";
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi"; import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { MaterialButton, MaterialIconButton, MaterialList, MaterialListItem, MaterialSwitch } from "@/utils/material"; import { MaterialButton, MaterialIconButton, MaterialList, MaterialListItem, MaterialSwitch } from "@/utils/material";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings"); const [activePanel, setActivePanel] = useState("notifications-settings");
@@ -77,13 +78,13 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
return ( return (
<> <>
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="settings-dialog"> <StyledDialog open={isOpen} onOpenChange={onOpenChange} className={styles.settingsDialog}>
<div id="settings-dialog-inner"> <div className={styles.settingsDialogInner}>
<div className="header"> <div className={styles.header}>
<MaterialIconButton icon="close" id="settings-close" onClick={() => onOpenChange(false)}></MaterialIconButton> <MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className="title">Настройки</div> <div className={styles.title}>Настройки</div>
</div> </div>
<div id="settings-menu"> <div className={styles.settingsMenu}>
<MaterialList> <MaterialList>
<MaterialListItem <MaterialListItem
icon="notifications--filled" icon="notifications--filled"
@@ -122,8 +123,8 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
О приложении О приложении
</MaterialListItem> </MaterialListItem>
</MaterialList> </MaterialList>
<div className="screen"> <div className={styles.screen}>
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}> <div className={`${styles.settingsPanel} ${activePanel === "notifications-settings" ? styles.active : ""}`}>
<h3>Уведомления</h3> <h3>Уведомления</h3>
{pushSupported && ( {pushSupported && (
<MaterialSwitch <MaterialSwitch
@@ -134,12 +135,12 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
)} )}
</div> </div>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}> <div className={`${styles.settingsPanel} ${activePanel === "security-settings" ? styles.active : ""}`}>
<h3>Безопасность</h3> <h3>Безопасность</h3>
<MaterialButton variant="tonal" onClick={() => setCpOpen(true)}>Изменить пароль</MaterialButton> <MaterialButton variant="tonal" onClick={() => setCpOpen(true)}>Изменить пароль</MaterialButton>
</div> </div>
<div id="devices-settings" className={`settings-panel ${activePanel === "devices-settings" ? "active" : ""}`}> <div className={`${styles.settingsPanel} ${activePanel === "devices-settings" ? styles.active : ""}`}>
<h3>Устройства</h3> <h3>Устройства</h3>
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}> <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<MaterialButton variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</MaterialButton> <MaterialButton variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</MaterialButton>
@@ -166,10 +167,10 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
</MaterialList> </MaterialList>
</div> </div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}> <div className={`${styles.settingsPanel} ${activePanel === "about-settings" ? styles.active : ""}`}>
<h3>О приложении</h3> <h3>О приложении</h3>
<p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p> <p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p>
<p><span className="product-name">{PRODUCT_NAME}</span></p> <p><span className={styles.productName}>{PRODUCT_NAME}</span></p>
</div> </div>
</div> </div>
</div> </div>
@@ -7,6 +7,7 @@ import Quote from "@/core/components/Quote";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu"; import { EmojiMenu } from "./EmojiMenu";
import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material"; import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material";
import styles from "@/pages/chat/css/ChatInput.module.scss";
interface ChatInputWrapperProps { interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void; onSendMessage: (message: string, files: File[]) => void;
@@ -144,8 +145,8 @@ export function ChatInputWrapper(
} }
return ( return (
<div className="chat-input-wrapper" ref={chatInputWrapperRef}> <div className={styles.chatInputWrapper} ref={chatInputWrapperRef}>
<form className="input-group" id="message-form" onSubmit={handleSubmit}> <form className={styles.inputGroup} id="message-form" onSubmit={handleSubmit}>
<AnimatePresence onExitComplete={onCloseEdit}> <AnimatePresence onExitComplete={onCloseEdit}>
{editVisible && editingMessage && ( {editVisible && editingMessage && (
<motion.div <motion.div
@@ -155,13 +156,13 @@ export function ChatInputWrapper(
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }} style={{ overflow: "hidden" }}
> >
<div className="reply-preview contextual-preview"> <div className={styles.contextualPreview}>
<MaterialIcon name="edit" /> <MaterialIcon name="edit" />
<Quote className="reply-content contextual-content" background="surfaceContainer"> <Quote className={`${styles.quote} ${styles.contextualContent}`} background="surfaceContainer">
<span className="reply-username">{editingMessage!.username}</span> <span className={styles.replyUsername}>{editingMessage!.username}</span>
<span className="reply-text">{editingMessage!.content}</span> <span className={styles.replyText}>{editingMessage!.content}</span>
</Quote> </Quote>
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearEdit}></MaterialIconButton> <MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearEdit}></MaterialIconButton>
</div> </div>
</motion.div> </motion.div>
)} )}
@@ -175,13 +176,13 @@ export function ChatInputWrapper(
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }} style={{ overflow: "hidden" }}
> >
<div className="reply-preview contextual-preview"> <div className={styles.contextualPreview}>
<MaterialIcon name="reply" /> <MaterialIcon name="reply" />
<Quote className="reply-content contextual-content" background="surfaceContainer"> <Quote className={`${styles.quote} ${styles.contextualContent}`} background="surfaceContainer">
<span className="reply-username">{replyTo!.username}</span> <span className={styles.replyUsername}>{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span> <span className={styles.replyText}>{replyTo!.content}</span>
</Quote> </Quote>
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearReply}></MaterialIconButton> <MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearReply}></MaterialIconButton>
</div> </div>
</motion.div> </motion.div>
)} )}
@@ -195,9 +196,9 @@ export function ChatInputWrapper(
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }} style={{ overflow: "hidden" }}
> >
<div className="attachments-preview contextual-preview"> <div className={`${styles.attachmentsPreview} ${styles.contextualPreview}`}>
<MaterialIcon name="attach_file" /> <MaterialIcon name="attach_file" />
<div className="attachments-chips"> <div className={styles.attachmentsChips}>
{selectedFiles.map((file, i) => ( {selectedFiles.map((file, i) => (
<mdui-chip <mdui-chip
key={i} key={i}
@@ -217,22 +218,22 @@ export function ChatInputWrapper(
</mdui-chip> </mdui-chip>
))} ))}
</div> </div>
<MaterialIconButton icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></MaterialIconButton> <MaterialIconButton icon="close" className={styles.replyCancel} onClick={() => setAttachmentsVisible(false)}></MaterialIconButton>
</div> </div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<div className="chat-input"> <div className={styles.chatInput}>
<div className="left-buttons"> <div className={styles.leftButtons}>
<MaterialIconButton <MaterialIconButton
icon="mood" icon="mood"
onClick={handleEmojiButtonClick} onClick={handleEmojiButtonClick}
onMouseDown={e => e.stopPropagation()} onMouseDown={e => e.stopPropagation()}
onMouseUp={e => e.stopPropagation()} onMouseUp={e => e.stopPropagation()}
className="emoji-btn" /> className={styles.emojiBtn} />
</div> </div>
<RichTextArea <RichTextArea
className="message-input" className={styles.messageInput}
id="message-input" id="message-input"
placeholder="Напишите сообщение..." placeholder="Напишите сообщение..."
autoComplete="off" autoComplete="off"
@@ -240,9 +241,9 @@ export function ChatInputWrapper(
rows={1} rows={1}
onTextChange={handleMessageChange} onTextChange={handleMessageChange}
onEnter={handleSubmit} /> onEnter={handleSubmit} />
<div className="buttons"> <div className={styles.buttons}>
<MaterialIconButton icon="attach_file" onClick={handleAttachClick} className="attach-btn"></MaterialIconButton> <MaterialIconButton icon="attach_file" onClick={handleAttachClick}></MaterialIconButton>
<button type="submit" className="send-btn"> <button type="submit" className={styles.sendBtn}>
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span> <span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button> </button>
</div> </div>
@@ -2,11 +2,11 @@ import { Message } from "./Message";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import type { Message as MessageType } from "@/core/types"; import type { Message as MessageType } from "@/core/types";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { useEffect, useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { MaterialDialog } from "@/core/components/Dialog";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types"; import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
import { MaterialButton } from "@/utils/material"; import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/right-panel.module.scss";
interface ChatMessagesProps { interface ChatMessagesProps {
messages?: MessageType[]; messages?: MessageType[];
@@ -22,8 +22,6 @@ interface ChatMessagesProps {
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) { export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
const { user } = useAppState(); const { user } = useAppState();
// Use prop messages (panels provide their own messages)
// Context menu state // Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({ const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false, isOpen: false,
@@ -31,18 +29,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
position: { x: 0, y: 0 } position: { x: 0, y: 0 }
}); });
// Delete dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
useEffect(() => {
if (!deleteDialogOpen) {
setToBeDeleted(null);
}
}, [deleteDialogOpen]);
function handleContextMenu(e: React.MouseEvent, message: MessageType) { function handleContextMenu(e: React.MouseEvent, message: MessageType) {
e.preventDefault(); e.preventDefault();
setContextMenu({ setContextMenu({
@@ -67,19 +53,17 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
if (onReplySelect) onReplySelect(message); if (onReplySelect) onReplySelect(message);
}; };
async function confirmDelete() {
if (!toBeDeleted || !user.authToken) return;
try {
onDelete?.(toBeDeleted.id);
} catch (error) {
console.error("Failed to delete message:", error);
}
setDeleteDialogOpen(false);
}
async function handleDelete(message: MessageType) { async function handleDelete(message: MessageType) {
setToBeDeleted({ id: message.id, isDm }); try {
setDeleteDialogOpen(true); await confirm({
headline: "Удалить сообщение?",
confirmText: "Удалить",
cancelText: "Отменить",
onConfirm: () => onDelete?.(message.id)
});
} catch (error) {
// User cancelled
}
} }
function handleRetry(message: MessageType) { function handleRetry(message: MessageType) {
@@ -127,7 +111,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
return ( return (
<> <>
<div className="chat-messages" id="chat-messages"> <div className={styles.chatMessages} id="chat-messages">
{messages.map((message: MessageType) => ( {messages.map((message: MessageType) => (
<Message <Message
key={message.id} key={message.id}
@@ -144,14 +128,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
{children} {children}
</div> </div>
<MaterialDialog
headline="Удалить сообщение?"
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}>
<MaterialButton slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</MaterialButton>
<MaterialButton slot="action" variant="filled" onClick={confirmDelete}>Удалить</MaterialButton>
</MaterialDialog>
{/* Context Menu */} {/* Context Menu */}
{contextMenu.message && ( {contextMenu.message && (
<MessageContextMenu <MessageContextMenu
@@ -170,8 +146,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
onOpenChange={handleContextMenuOpenChange} onOpenChange={handleContextMenuOpenChange}
/> />
)} )}
</> </>
); );
} }
+23 -19
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData"; import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
import type { Size2D } from "@/core/types"; import type { Size2D } from "@/core/types";
import styles from "@/pages/chat/css/EmojiMenu.module.scss";
interface BaseEmojiMenuProps { interface BaseEmojiMenuProps {
isOpen: boolean; isOpen: boolean;
@@ -118,26 +119,29 @@ export function EmojiMenu(props: EmojiMenuProps) {
return ( return (
<div <div
ref={menuRef} ref={menuRef}
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`} className={`${styles.emojiMenu} ${isOpen ? styles.open : ""} ${mode === "integrated" ? styles.integrated : ""}`}
style={mode === "standalone" && position ? { style={{
position: "fixed", pointerEvents: isOpen ? "auto" : "none",
left: position.x, ...(mode === "standalone" && position ? {
bottom: position.y, position: "fixed",
zIndex: 1000, left: position.x,
pointerEvents: isOpen ? "auto" : "none" bottom: position.y,
} : { zIndex: 1000,
pointerEvents: isOpen ? "auto" : "none" } : {}),
...(mode === "integrated" ? {
position: "relative",
} : {})
}} }}
> >
<div className="emoji-menu-header"> <div className={styles.emojiMenuHeader}>
<div ref={tabsRef} className="emoji-category-tabs"> <div ref={tabsRef} className={styles.emojiCategoryTabs}>
{EMOJI_CATEGORIES.map((category) => ( {EMOJI_CATEGORIES.map((category) => (
<button <button
key={category.name} key={category.name}
ref={(el) => { ref={(el) => {
if (el) tabRefs.current.set(category.name, el); if (el) tabRefs.current.set(category.name, el);
}} }}
className={`emoji-category-tab ${activeCategory === category.name ? "active" : ""}`} className={`${styles.emojiCategoryTab} ${activeCategory === category.name ? styles.active : ""}`}
onClick={() => scrollToCategory(category.name)} onClick={() => scrollToCategory(category.name)}
title={category.name} title={category.name}
> >
@@ -149,7 +153,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
<div <div
ref={scrollRef} ref={scrollRef}
className="emoji-grid" className={styles.emojiGrid}
onScroll={handleScroll} onScroll={handleScroll}
> >
{EMOJI_CATEGORIES.map((category) => { {EMOJI_CATEGORIES.map((category) => {
@@ -161,17 +165,17 @@ export function EmojiMenu(props: EmojiMenuProps) {
ref={(el) => { ref={(el) => {
if (el) categoryRefs.current.set(category.name, el); if (el) categoryRefs.current.set(category.name, el);
}} }}
className="emoji-category-section" className={styles.emojiCategorySection}
> >
<h3 className="emoji-category-title"> <h3 className={styles.emojiCategoryTitle}>
{category.name.charAt(0).toUpperCase() + category.name.slice(1)} {category.name.charAt(0).toUpperCase() + category.name.slice(1)}
</h3> </h3>
{emojis.length > 0 ? ( {emojis.length > 0 ? (
<div className="emoji-category-grid"> <div className={styles.emojiCategoryGrid}>
{emojis.map((emoji, index) => ( {emojis.map((emoji, index) => (
<button <button
key={`${category.name}-${index}`} key={`${category.name}-${index}`}
className="emoji-item" className={styles.emojiItem}
onClick={() => handleEmojiClick(emoji)} onClick={() => handleEmojiClick(emoji)}
title={emoji} title={emoji}
> >
@@ -180,7 +184,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
))} ))}
</div> </div>
) : ( ) : (
<div className="emoji-empty-state"> <div className={styles.emojiEmptyState}>
<span>No {category.name} emojis</span> <span>No {category.name} emojis</span>
</div> </div>
)} )}
+29 -28
View File
@@ -17,6 +17,7 @@ import { useImmer } from "use-immer";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { parseProfileLink } from "@/core/profileLinks"; import { parseProfileLink } from "@/core/profileLinks";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material"; import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
import styles from "@/pages/chat/css/Message.module.scss";
interface MessageReactionsProps { interface MessageReactionsProps {
reactions?: Reaction[]; reactions?: Reaction[];
@@ -111,7 +112,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
} }
return ( return (
<div className="message-reactions"> <div className={styles.messageReactions}>
{visibleReactions.map((reaction, index) => { {visibleReactions.map((reaction, index) => {
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id); const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
const isAnimating = animatingReactions.has(reaction.emoji); const isAnimating = animatingReactions.has(reaction.emoji);
@@ -119,12 +120,12 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
return ( return (
<button <button
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`} key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`} className={`${styles.reactionButton} ${hasUserReacted ? styles.reacted : ""} ${isAnimating ? styles.removing : ""}`}
onClick={() => onReactionClick(reaction.emoji)} onClick={() => onReactionClick(reaction.emoji)}
title={reaction.users.map(u => u.username).join(", ")} title={reaction.users.map(u => u.username).join(", ")}
> >
<span className="reaction-emoji">{reaction.emoji}</span> <span className={styles.reactionEmoji}>{reaction.emoji}</span>
<span className="reaction-count">{reaction.count}</span> <span className={styles.reactionCount}>{reaction.count}</span>
</button> </button>
); );
})} })}
@@ -177,7 +178,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
// Now process @mentions that aren't in existing links // Now process @mentions that aren't in existing links
content = content.replace(/@([a-zA-Z0-9_.-]+)/g, (match, username) => { content = content.replace(/@([a-zA-Z0-9_.-]+)/g, (match, username) => {
return `<a href="https://fromchat.ru/@${username}" class="mention-link">${match}</a>`; return `<a href="https://fromchat.ru/@${username}" class="${styles.mentionLink}">${match}</a>`;
}); });
// Restore the original links // Restore the original links
@@ -188,7 +189,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
return { return {
__html: DOMPurify.sanitize(parse(content, { async: false })).trim() __html: DOMPurify.sanitize(parse(content, { async: false })).trim()
}; };
}, [message.content]); }, [message.content, styles.mentionLink]);
// Auto-decrypt images in DMs // Auto-decrypt images in DMs
useEffect(() => { useEffect(() => {
@@ -473,12 +474,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
return ( return (
<> <>
<div <div
className={`message ${isAuthor ? "sent" : "received"} ${isEmojiMessage ? "emoji-message" : ""} ${isSingleEmojiMessage ? "single-emoji" : ""}`} className={`${styles.message} ${isAuthor ? styles.sent : styles.received} ${isEmojiMessage ? styles.emojiMessage : ""} ${isSingleEmojiMessage ? "" : ""}`}
data-id={message.id} data-id={message.id}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
{!isAuthor && !isDm && ( {!isAuthor && !isDm && (
<div className="message-profile-pic" onClick={handleProfileClick}> <div className={styles.messageProfilePic} onClick={handleProfileClick}>
<img <img
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)} src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
alt={message.username} alt={message.username}
@@ -489,10 +490,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
</div> </div>
)} )}
<div className="message-inner"> <div className={styles.messageInner}>
{!isAuthor && !isDm && !isSingleEmojiMessage && ( {!isAuthor && !isDm && !isSingleEmojiMessage && (
<div <div
className="message-username" className={styles.messageUsername}
onClick={handleProfileClick}> onClick={handleProfileClick}>
{message.username} {message.username}
<StatusBadge <StatusBadge
@@ -504,19 +505,19 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
)} )}
{message.reply_to && ( {message.reply_to && (
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}> <Quote className={`${styles.replyPreview} ${styles.contextualContent}`} background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className="reply-username">{message.reply_to.username}</span> <span className={styles.replyUsername}>{message.reply_to.username}</span>
<span className="reply-text">{message.reply_to.content}</span> <span className={styles.replyText}>{message.reply_to.content}</span>
</Quote> </Quote>
)} )}
<div <div
className={`message-content ${isEmojiMessage ? "emoji-content" : ""} ${isSingleEmojiMessage ? "single-emoji-content" : ""}`} className={`${styles.messageContent} ${isEmojiMessage ? styles.emojiContent : ""} ${isSingleEmojiMessage ? styles.singleEmojiContent : ""}`}
dangerouslySetInnerHTML={formattedMessage} dangerouslySetInnerHTML={formattedMessage}
onClick={handleLinkClick} /> onClick={handleLinkClick} />
{message.files && message.files.length > 0 && ( {message.files && message.files.length > 0 && (
<MaterialList className="message-attachments"> <MaterialList className={styles.messageAttachments}>
{message.files.map((file, idx) => { {message.files.map((file, idx) => {
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
const isEncryptedDm = Boolean(isDm && file.encrypted); const isEncryptedDm = Boolean(isDm && file.encrypted);
@@ -526,9 +527,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
const isSending = message.runtimeData?.sendingState?.status === 'sending'; const isSending = message.runtimeData?.sendingState?.status === 'sending';
return ( return (
<div className="attachment" key={idx}> <div className={styles.attachment} key={idx}>
{isImage ? ( {isImage ? (
<div className="image-wrapper"> <div className={styles.imageWrapper}>
<img <img
ref={(el) => { ref={(el) => {
if (el) imageRefs.current.set(file.path, el); if (el) imageRefs.current.set(file.path, el);
@@ -537,10 +538,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
alt={file.name || "image"} alt={file.name || "image"}
onClick={(e) => handleImageClick(file, e.currentTarget)} onClick={(e) => handleImageClick(file, e.currentTarget)}
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })} onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`} className={`${styles.attachementImage} ${loadedImages.has(file.path) ? "" : styles.loading}`}
/> />
{(!loadedImages.has(file.path) || isSending) && ( {(!loadedImages.has(file.path) || isSending) && (
<div className="loading-overlay"> <div className={styles.loadingOverlay}>
<MaterialCircularProgress /> <MaterialCircularProgress />
</div> </div>
)} )}
@@ -554,7 +555,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}} }}
> >
<MaterialListItem> <MaterialListItem>
<span className="with-icon-gap"> <span className={styles.withIconGap}>
{isDownloading ? <MaterialCircularProgress /> : null} {isDownloading ? <MaterialCircularProgress /> : null}
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
</span> </span>
@@ -573,7 +574,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
messageId={message.id} messageId={message.id}
/> />
<div className="message-time"> <div className={styles.messageTime}>
{formatTime(message.timestamp)} {formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined} {message.is_edited ? " (edited)" : undefined}
@@ -582,15 +583,15 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
)} )}
{isAuthor && message.runtimeData?.sendingState && ( {isAuthor && message.runtimeData?.sendingState && (
<span className="message-status-indicator"> <span className={styles.messageStatusIndicator}>
{message.runtimeData.sendingState.status === 'sending' && ( {message.runtimeData.sendingState.status === 'sending' && (
<MaterialCircularProgress style={{ width: '16px', height: '16px' }} /> <MaterialCircularProgress style={{ width: '16px', height: '16px' }} />
)} )}
{message.runtimeData.sendingState.status === 'failed' && ( {message.runtimeData.sendingState.status === 'failed' && (
<span className="material-symbols error-icon">error</span> <span className={`material-symbols ${styles.errorIcon}`}>error</span>
)} )}
{message.runtimeData.sendingState.status === 'sent' && ( {message.runtimeData.sendingState.status === 'sent' && (
<span className="material-symbols success-icon">check</span> <span className={`material-symbols ${styles.successIcon}`}>check</span>
)} )}
</span> </span>
)} )}
@@ -601,12 +602,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
{/* Fullscreen Image Viewer with shared-element like transition */} {/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && createPortal( {fullscreenImage && createPortal(
<div <div
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`} className={`${styles.fullscreenImageOverlay} ${isAnimatingOpen ? "" : styles.closing}`}
onClick={closeFullscreen}> onClick={closeFullscreen}>
<img <img
src={fullscreenImage.src} src={fullscreenImage.src}
alt={fullscreenImage.name} alt={fullscreenImage.name}
className={`fullscreen-animated-image ${isAnimatingOpen ? "to-end" : "to-start"}`} className={styles.fullscreenAnimatedImage}
style={{ style={{
left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`, left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`,
top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`, top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`,
@@ -615,10 +616,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}} }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
/> />
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}> <div className={`${styles.fullscreenControls} ${styles.topRight}`} onClick={e => e.stopPropagation()}>
<MaterialIconButton icon="close" onClick={closeFullscreen} /> <MaterialIconButton icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? ( {isDownloadingFullscreen ? (
<div className="progress-wrapper"> <div className={styles.progressWrapper}>
<MaterialCircularProgress /> <MaterialCircularProgress />
</div> </div>
) : ( ) : (
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "@/core/types"; import type { Message, Size2D } from "@/core/types";
import { EmojiMenu } from "./EmojiMenu"; import { EmojiMenu } from "./EmojiMenu";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import styles from "@/pages/chat/css/MessageContextMenu.module.scss";
interface MessageContextMenuProps { interface MessageContextMenuProps {
message: Message; message: Message;
@@ -39,9 +40,8 @@ export function MessageContextMenu({
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [reactionBarPosition, setReactionBarPosition] = useState<Size2D>({ x: 0, y: 0 }); const [reactionBarPosition, setReactionBarPosition] = useState<Size2D>({ x: 0, y: 0 });
const [contextMenuPosition, setContextMenuPosition] = useState<Size2D>(position); const [contextMenuPosition, setContextMenuPosition] = useState<Size2D>(position);
const [animationClass, setAnimationClass] = useState('entering'); const [animationClass, setAnimationClass] = useState<keyof typeof styles>(styles.entering);
const [reactionBarAnimationClass, setReactionBarAnimationClass] = useState('entering'); const [reactionBarAnimationClass, setReactionBarAnimationClass] = useState<keyof typeof styles>(styles.entering);
const [reactionBarSide, setReactionBarSide] = useState<'left' | 'right'>('left');
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false); const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null); const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
const [expandUpward, setExpandUpward] = useState(false); const [expandUpward, setExpandUpward] = useState(false);
@@ -74,8 +74,7 @@ export function MessageContextMenu({
let menuY = position.y; let menuY = position.y;
let reactionX = position.x; let reactionX = position.x;
let reactionY = position.y - reactionBarRect.height - 10; // Position above menu let reactionY = position.y - reactionBarRect.height - 10; // Position above menu
let animation = 'entering'; let animation: keyof typeof styles = styles.entering;
let reactionSide: 'left' | 'right' = 'left';
let reactionPositionedRight = false; let reactionPositionedRight = false;
// Check if reaction bar would overflow at the top // Check if reaction bar would overflow at the top
@@ -83,19 +82,16 @@ export function MessageContextMenu({
// Position reaction bar to the right side of the context menu instead // Position reaction bar to the right side of the context menu instead
reactionX = menuX + contextMenuRect.width + 10; reactionX = menuX + contextMenuRect.width + 10;
reactionY = menuY; // Align with menu top reactionY = menuY; // Align with menu top
reactionSide = 'right';
reactionPositionedRight = true; reactionPositionedRight = true;
animation = 'entering-right'; // Use right-side animation animation = styles.enteringRight; // Use right-side animation
} else { } else {
// Try positioning above menu first // Try positioning above menu first
reactionSide = 'left';
// Check if shared rect would overflow horizontally // Check if shared rect would overflow horizontally
if (menuX + sharedRect.width > viewportWidth) { if (menuX + sharedRect.width > viewportWidth) {
menuX = position.x - contextMenuRect.width; menuX = position.x - contextMenuRect.width;
reactionX = menuX; reactionX = menuX;
animation = 'entering-left'; animation = styles.enteringLeft;
reactionSide = 'right';
} }
} }
@@ -111,14 +107,13 @@ export function MessageContextMenu({
if (reactionPositionedRight && reactionX + reactionBarRect.width > viewportWidth) { if (reactionPositionedRight && reactionX + reactionBarRect.width > viewportWidth) {
// Position to the left side instead // Position to the left side instead
reactionX = menuX - reactionBarRect.width - 10; reactionX = menuX - reactionBarRect.width - 10;
reactionSide = 'left';
} }
// Check if shared rect would overflow bottom edge (only if reaction bar is above) // Check if shared rect would overflow bottom edge (only if reaction bar is above)
if (!reactionPositionedRight && menuY + sharedRect.height > viewportHeight) { if (!reactionPositionedRight && menuY + sharedRect.height > viewportHeight) {
menuY = viewportHeight - sharedRect.height; menuY = viewportHeight - sharedRect.height;
reactionY = menuY - reactionBarRect.height - 10; reactionY = menuY - reactionBarRect.height - 10;
animation = 'entering-up'; animation = styles.enteringUp;
} }
// Ensure menu doesn't go off the right edge // Ensure menu doesn't go off the right edge
@@ -133,7 +128,6 @@ export function MessageContextMenu({
setReactionBarPosition({ x: reactionX, y: reactionY }); setReactionBarPosition({ x: reactionX, y: reactionY });
setAnimationClass(animation); setAnimationClass(animation);
setReactionBarAnimationClass(animation); setReactionBarAnimationClass(animation);
setReactionBarSide(reactionSide);
} }
}); });
@@ -147,7 +141,9 @@ export function MessageContextMenu({
if (isOpen && !isClosing) { if (isOpen && !isClosing) {
// Check if the click is on a context menu element or reaction bar // Check if the click is on a context menu element or reaction bar
const target = event.target as Element; const target = event.target as Element;
if (!target.closest('.context-menu') && !target.closest('.context-menu-reaction-bar')) { // Use refs instead of class selectors for CSS modules
if ((!contextMenuRef.current || !contextMenuRef.current.contains(target)) &&
(!reactionBarRef.current || !reactionBarRef.current.contains(target))) {
handleClose(); handleClose();
} }
} }
@@ -181,17 +177,15 @@ export function MessageContextMenu({
function handleClose() { function handleClose() {
setIsClosing(true); setIsClosing(true);
// Set appropriate closing animation based on opening animation setAnimationClass(styles.closing);
const closingAnimation = animationClass.replace('entering', 'closing'); setReactionBarAnimationClass(styles.closing);
setAnimationClass(closingAnimation);
setReactionBarAnimationClass(closingAnimation);
// Wait for animation to complete before calling onOpenChange // Wait for animation to complete before calling onOpenChange
setTimeout(() => { setTimeout(() => {
onOpenChange(false); onOpenChange(false);
setIsClosing(false); setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening setAnimationClass(styles.entering); // Reset for next opening
setReactionBarAnimationClass('entering'); // Reset for next opening setReactionBarAnimationClass(styles.entering); // Reset for next opening
// Reset emoji menu state after context menu animation completes // Reset emoji menu state after context menu animation completes
setIsEmojiMenuExpanded(false); setIsEmojiMenuExpanded(false);
setInitialDimensions(null); setInitialDimensions(null);
@@ -307,7 +301,7 @@ export function MessageContextMenu({
{/* Reaction Bar */} {/* Reaction Bar */}
<div <div
ref={reactionBarRef} ref={reactionBarRef}
className={`context-menu-reaction-bar ${reactionBarSide} ${reactionBarAnimationClass} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`} className={`${styles.contextMenuReactionBar} ${reactionBarAnimationClass} ${isEmojiMenuExpanded ? styles.expanded : ""} ${expandUpward ? styles.expandUpward : ""}`}
style={{ style={{
position: 'fixed', position: 'fixed',
...(isEmojiMenuExpanded && expandUpward ...(isEmojiMenuExpanded && expandUpward
@@ -326,11 +320,11 @@ export function MessageContextMenu({
}} }}
onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}>
{!isEmojiMenuExpanded ? ( {!isEmojiMenuExpanded ? (
<div className="reaction-bar-content"> <div className={styles.reactionBarContent}>
{QUICK_REACTIONS.map((emoji, index) => ( {QUICK_REACTIONS.map((emoji, index) => (
<button <button
key={index} key={index}
className="reaction-emoji-button" className={styles.reactionEmojiButton}
onClick={async () => await handleReactionClick(emoji)} onClick={async () => await handleReactionClick(emoji)}
title={emoji} title={emoji}
> >
@@ -338,7 +332,7 @@ export function MessageContextMenu({
</button> </button>
))} ))}
<button <button
className="reaction-expand-button" className={styles.reactionExpandButton}
onClick={handleExpandClick} onClick={handleExpandClick}
title="More emojis" title="More emojis"
> >
@@ -348,7 +342,7 @@ export function MessageContextMenu({
) : ( ) : (
<div <div
ref={emojiMenuRef} ref={emojiMenuRef}
className="emoji-menu-wrapper"> className={styles.emojiMenuWrapper}>
<EmojiMenu <EmojiMenu
isOpen={true} isOpen={true}
onClose={handleClose} onClose={handleClose}
@@ -362,7 +356,7 @@ export function MessageContextMenu({
{/* Context Menu */} {/* Context Menu */}
<div <div
ref={contextMenuRef} ref={contextMenuRef}
className={`context-menu ${animationClass} ${isEmojiMenuExpanded ? "faded" : ""}`} className={`${styles.contextMenu} ${animationClass} ${isEmojiMenuExpanded ? styles.faded : ""}`}
style={{ style={{
position: 'fixed', position: 'fixed',
top: `${contextMenuPosition.y}px`, top: `${contextMenuPosition.y}px`,
@@ -373,7 +367,7 @@ export function MessageContextMenu({
{actions.map((action, i) => ( {actions.map((action, i) => (
action.show && ( action.show && (
<div <div
className="context-menu-item" className={styles.contextMenuItem}
onClick={action.onClick} onClick={action.onClick}
key={i}> key={i}>
<span className="material-symbols">{action.icon}</span> <span className="material-symbols">{action.icon}</span>
@@ -15,6 +15,8 @@ import { OnlineStatus } from "./OnlineStatus";
import { typingManager } from "@/core/typingManager"; import { typingManager } from "@/core/typingManager";
import { PublicChatPanel } from "./panels/PublicChatPanel"; import { PublicChatPanel } from "./panels/PublicChatPanel";
import { MaterialIcon, MaterialIconButton } from "@/utils/material"; import { MaterialIcon, MaterialIconButton } from "@/utils/material";
import styles from "@/pages/chat/css/layout.module.scss";
import rightPanelStyles from "@/pages/chat/css/right-panel.module.scss";
interface MessagePanelRendererProps { interface MessagePanelRendererProps {
panel: MessagePanel | null; panel: MessagePanel | null;
@@ -196,7 +198,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const panelKey = chat.activePanel?.getState().title || "empty"; const panelKey = chat.activePanel?.getState().title || "empty";
return ( return (
<div className="chat-container"> <div className={styles.chatContainer}>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={panelKey} key={panelKey}
@@ -204,12 +206,11 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="chat-wrapper" className={rightPanelStyles.chatWrapper}
> >
<div <div
ref={messagePanelRef} ref={messagePanelRef}
className="chat-main" className={rightPanelStyles.chatMain}
id="chat-inner"
onDragEnter={panel ? (e) => { onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return; if (!e.dataTransfer) return;
e.preventDefault(); e.preventDefault();
@@ -242,16 +243,16 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
setIsDragging(false); setIsDragging(false);
dragCounterRef.current = 0; dragCounterRef.current = 0;
} : undefined}> } : undefined}>
<div className="chat-header"> <div className={rightPanelStyles.chatHeader}>
<img <img
src={panelState?.profilePicture || defaultAvatar} src={panelState?.profilePicture || defaultAvatar}
alt="Avatar" alt="Avatar"
className="chat-header-avatar" className={rightPanelStyles.chatHeaderAvatar}
onClick={handleProfileClick} onClick={handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }} style={{ cursor: panel ? "pointer" : "default" }}
/> />
<div className="chat-header-info"> <div className={rightPanelStyles.chatHeaderInfo}>
<div className="info-chat"> <div className={rightPanelStyles.infoChat}>
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4> <h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<ChatHeaderText panel={panel} /> <ChatHeaderText panel={panel} />
</div> </div>
@@ -262,7 +263,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</div> </div>
{panelState?.isLoading ? ( {panelState?.isLoading ? (
<div className="chat-messages" id="chat-messages"> <div className={rightPanelStyles.chatMessages} id="chat-messages">
<div style={{ <div style={{
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
@@ -300,7 +301,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</ChatMessages> </ChatMessages>
) : ( ) : (
<div className="chat-messages" id="chat-messages"> <div className={rightPanelStyles.chatMessages} id="chat-messages">
<div style={{ <div style={{
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
@@ -314,30 +315,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
)} )}
{panel && ( {panel && (
<> <ChatInputWrapper
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
>
<div className="file-overlay-wrapper">
<div className="file-overlay-inner">
<MaterialIcon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<ChatInputWrapper
onSendMessage={(text, files) => { onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files); panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null); setReplyTo(null);
@@ -393,9 +371,29 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
} }
}} }}
/> />
</>
)} )}
</div> </div>
{panel && (
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className={rightPanelStyles.fileOverlay}
>
<div className={rightPanelStyles.fileOverlayWrapper}>
<div className={rightPanelStyles.fileOverlayInner}>
<MaterialIcon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
@@ -6,6 +6,7 @@
*/ */
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface OnlineIndicatorProps { interface OnlineIndicatorProps {
userId: number; userId: number;
@@ -22,8 +23,8 @@ export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps
} }
return ( return (
<div className={`online-indicator ${className}`}> <div className={`${styles.onlineIndicator} ${className}`}>
<div className="indicator-dot online"></div> <div className={`${styles.indicatorDot} ${styles.online}`}></div>
</div> </div>
); );
} }
@@ -6,6 +6,7 @@
*/ */
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface OnlineStatusProps { interface OnlineStatusProps {
userId: number; userId: number;
@@ -38,9 +39,9 @@ export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps
} }
return ( return (
<div className="online-status"> <div className={styles.onlineStatus}>
<div className={`status-dot ${status?.online ? "online" : "offline"}`}></div> <div className={`${styles.statusDot} ${status?.online ? styles.online : styles.offline}`}></div>
<span className="status-text"> <span className={styles.statusText}>
{!status ? "Загрузка..." : status?.online ? "В сети" : "Не в сети"} {!status ? "Загрузка..." : status?.online ? "В сети" : "Не в сети"}
</span> </span>
{showLastSeen && status && !status.online && ( {showLastSeen && status && !status.online && (
@@ -6,6 +6,7 @@
*/ */
import { useMemo } from "react"; import { useMemo } from "react";
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface TypingIndicatorProps { interface TypingIndicatorProps {
typingUsers: string[]; // Array of usernames who are typing typingUsers: string[]; // Array of usernames who are typing
@@ -23,13 +24,13 @@ export function TypingIndicator({ typingUsers }: TypingIndicatorProps) {
}, [typingUsers]); }, [typingUsers]);
return ( return (
<div className="typing-indicator"> <div className={styles.typingIndicator}>
<div className="typing-dots"> <div className={styles.typingDots}>
<span></span> <span />
<span></span> <span />
<span></span> <span />
</div> </div>
<span className="typing-text">{typingText}</span> <span className={styles.typingText}>{typingText}</span>
</div> </div>
); );
} }
@@ -1,10 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useAppState, type CallStatus } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import useCall from "@/pages/chat/hooks/useCall"; import useCall from "@/pages/chat/hooks/useCall";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { id } from "@/utils/utils"; import { id } from "@/utils/utils";
import { MaterialIconButton } from "@/utils/material"; import { MaterialIconButton } from "@/utils/material";
import { motion, AnimatePresence } from "motion/react";
import styles from "@/pages/chat/css/callWindow.module.scss";
export function CallWindow() { export function CallWindow() {
const { chat, toggleCallMinimize, user } = useAppState(); const { chat, toggleCallMinimize, user } = useAppState();
@@ -26,20 +28,11 @@ export function CallWindow() {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [callDuration, setCallDuration] = useState(0); const [callDuration, setCallDuration] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
const [wasMinimized, setWasMinimized] = useState(false);
const [callData, setCallData] = useState<{
remoteUsername: string | null;
status: CallStatus;
isInitiator: boolean;
isMuted: boolean;
} | null>(null);
const status = callData?.status || call.status; const status = call.status;
const remoteUsername = callData?.remoteUsername || call.remoteUsername; const remoteUsername = call.remoteUsername;
const isInitiator = callData?.isInitiator || call.isInitiator; const isInitiator = call.isInitiator;
const isMuted = callData?.isMuted || call.isMuted; const isMuted = call.isMuted;
useEffect(() => { useEffect(() => {
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
@@ -57,55 +50,6 @@ export function CallWindow() {
}; };
}, [call.status, call.startTime]); }, [call.status, call.startTime]);
// Preserve call data during exit animation
useEffect(() => {
if (call.isActive) {
setCallData({
remoteUsername: call.remoteUsername,
status: call.status,
isInitiator: call.isInitiator,
isMuted: call.isMuted
});
}
}, [call.isActive, call.remoteUsername, call.status, call.isInitiator, call.isMuted]);
// Track minimized state for exit animation
useEffect(() => {
if (call.isActive) {
setWasMinimized(call.isMinimized);
}
}, [call.isActive, call.isMinimized]);
// Handle visibility animation with entrance and exit delays
useEffect(() => {
if (call.isActive) {
setShouldRender(true);
// Small delay to ensure DOM is ready, then trigger animation
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsVisible(true);
});
});
} else {
if (shouldRender) {
// Call ended - start exit animation
setIsVisible(false);
// After animation completes, stop rendering
const timer = setTimeout(() => {
setShouldRender(false);
setCallData(null);
setWasMinimized(false);
}, 400); // Match the CSS transition duration
return () => clearTimeout(timer);
} else {
// Call not active and not rendered - ensure clean state
setShouldRender(false);
setIsVisible(false);
setCallData(null);
setWasMinimized(false);
}
}
}, [call.isActive, shouldRender, wasMinimized]);
// Handle dragging for PiP mode // Handle dragging for PiP mode
useEffect(() => { useEffect(() => {
@@ -135,14 +79,6 @@ export function CallWindow() {
}; };
}, [isDragging, call.isMinimized, dragOffset]); }, [isDragging, call.isMinimized, dragOffset]);
// Cleanup effect to reset state when component unmounts
useEffect(() => {
return () => {
setIsVisible(false);
setShouldRender(false);
setCallData(null);
};
}, []);
function formatDuration(seconds: number) { function formatDuration(seconds: number) {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
@@ -166,64 +102,75 @@ export function CallWindow() {
function getGradientClass() { function getGradientClass() {
switch (status) { switch (status) {
case "calling": case "calling":
return "gradient-calling"; return styles.gradientCalling;
case "connecting": case "connecting":
return "gradient-connecting"; return styles.gradientConnecting;
case "active": case "active":
return "gradient-active"; return styles.gradientActive;
default: default:
return "gradient-default"; return styles.gradientDefault;
} }
} }
const isMinimized = call.isMinimized;
return ( return (
createPortal( createPortal(
<> <>
<audio <audio
ref={remoteAudioRef} ref={remoteAudioRef}
className="remote-audio" className={styles.remoteAudio}
autoPlay autoPlay
playsInline playsInline
controls /> controls />
{shouldRender && ( <AnimatePresence>
<div {call.isActive && (
className={`call-window ${(call.isActive ? call.isMinimized : wasMinimized) ? "minimized" : "maximized"} ${isDragging ? "dragging" : ""} ${getGradientClass()} ${isVisible ? "visible" : "hidden"}`} <motion.div
style={(call.isActive ? call.isMinimized : wasMinimized) ? { className={`${styles.callWindow} ${isMinimized ? styles.minimized : styles.maximized} ${isDragging ? styles.dragging : ""} ${getGradientClass()}`}
left: pipPosition.x, style={isMinimized ? {
top: pipPosition.y left: pipPosition.x,
} : undefined} top: pipPosition.y
onMouseDown={(e) => { } : undefined}
if (call.isMinimized) { initial={false}
// Only start dragging if not clicking on a button exit={isMinimized ?
// TODO change it to e.stopPropagation() on the buttons { opacity: 0, scale: 0.7 } :
if (!e.target.closest("mdui-button-icon")) { { opacity: 0, y: -100 }
setIsDragging(true);
setDragOffset({
x: e.clientX - pipPosition.x,
y: e.clientY - pipPosition.y
});
}
} }
}} transition={{
> opacity: { duration: 0.4 },
<div className="call-header"> scale: { duration: 0.4 },
<div className="window-controls"> y: { duration: 0.4 }
}}
onMouseDown={(e) => {
if (isMinimized) {
if (!e.target.closest("mdui-button-icon")) {
setIsDragging(true);
setDragOffset({
x: e.clientX - pipPosition.x,
y: e.clientY - pipPosition.y
});
}
}
}}
>
<div className={styles.callHeader}>
<div className={styles.windowControls}>
<MaterialIconButton <MaterialIconButton
onClick={toggleCallMinimize} onClick={toggleCallMinimize}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"} icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className="window-control-btn" className={styles.windowControlBtn}
/> />
</div> </div>
<div className="call-header-info"> <div className={styles.callHeaderInfo}>
<h3 className="username">{remoteUsername}</h3> <h3 className={styles.username}>{remoteUsername}</h3>
<p className="status">{getStatusText()}</p> <p className={styles.status}>{getStatusText()}</p>
{!call.isMinimized && call.encryptionEmojis.length > 0 && ( {!call.isMinimized && call.encryptionEmojis.length > 0 && (
<div className="encryption-emojis"> <div className={styles.encryptionEmojis}>
{call.encryptionEmojis.map((emoji, index) => ( {call.encryptionEmojis.map((emoji, index) => (
<span key={index} className="encryption-emoji"> <span key={index} className={styles.encryptionEmoji}>
{emoji} {emoji}
</span> </span>
))} ))}
@@ -232,75 +179,75 @@ export function CallWindow() {
</div> </div>
</div> </div>
<div className={`call-content ${(call.isSharingScreen || call.isRemoteScreenSharing) ? "with-screen-share" : ""}`}> <div className={`${styles.callContent} ${(call.isSharingScreen || call.isRemoteScreenSharing) ? styles.withScreenShare : ""}`}>
{/* Main screen share area - takes most space when active */} {/* Main screen share area - takes most space when active */}
<div className="screen-share-area"> <div className={styles.screenShareArea}>
{/* Local screen share */} {/* Local screen share */}
<div <div
className="video-tile screen-share-tile local-screen-share" className={`${styles.videoTile} ${styles.screenShareTile} ${styles.localScreenShare}`}
style={{ display: call.isSharingScreen ? "flex" : "none" }}> style={{ display: call.isSharingScreen ? "flex" : "none" }}>
<video <video
ref={localScreenShareRef} ref={localScreenShareRef}
className="video-element screen-share-video" className={`${styles.videoElement} ${styles.screenShareVideo}`}
autoPlay autoPlay
playsInline playsInline
muted /> muted />
<div className="tile-label">Your Screen</div> <div className={styles.tileLabel}>Your Screen</div>
</div> </div>
{/* Remote screen share */} {/* Remote screen share */}
<div <div
className="video-tile screen-share-tile remote-screen-share" className={`${styles.videoTile} ${styles.screenShareTile} ${styles.remoteScreenShare}`}
style={{ display: call.isRemoteScreenSharing ? "flex" : "none" }}> style={{ display: call.isRemoteScreenSharing ? "flex" : "none" }}>
<video <video
ref={remoteScreenShareRef} ref={remoteScreenShareRef}
className="video-element screen-share-video" className={`${styles.videoElement} ${styles.screenShareVideo}`}
autoPlay autoPlay
playsInline /> playsInline />
<div className="tile-label">{remoteUsername}&apos;s Screen</div> <div className={styles.tileLabel}>{remoteUsername}&apos;s Screen</div>
</div> </div>
</div> </div>
{/* Video tiles sidebar - appears on right when screen share is active */} {/* Video tiles sidebar - appears on right when screen share is active */}
<div className="video-tiles-sidebar"> <div className={styles.videoTilesSidebar}>
{/* Local video tile */} {/* Local video tile */}
<div className="video-tile local-video"> <div className={`${styles.videoTile} ${styles.localVideo}`}>
<video <video
ref={localVideoRef} ref={localVideoRef}
className="video-element" className={styles.videoElement}
autoPlay autoPlay
playsInline playsInline
muted muted
style={{ display: call.isVideoEnabled ? "block" : "none" }} /> style={{ display: call.isVideoEnabled ? "block" : "none" }} />
{!call.isVideoEnabled && ( {!call.isVideoEnabled && (
<div className="video-placeholder"> <div className={styles.videoPlaceholder}>
<img src={defaultAvatar} alt="Avatar" className="placeholder-avatar" /> <img src={defaultAvatar} alt="Avatar" className={styles.placeholderAvatar} />
<span className="placeholder-username">{user.currentUser?.username || "You"}</span> <span className={styles.placeholderUsername}>{user.currentUser?.username || "You"}</span>
</div> </div>
)} )}
<div className="tile-label">You</div> <div className={styles.tileLabel}>You</div>
</div> </div>
{/* Remote video tile */} {/* Remote video tile */}
<div className="video-tile remote-video"> <div className={`${styles.videoTile} ${styles.remoteVideo}`}>
<video <video
ref={remoteVideoRef} ref={remoteVideoRef}
className="video-element" className={styles.videoElement}
autoPlay autoPlay
playsInline playsInline
style={{ display: call.isRemoteVideoEnabled ? "block" : "none" }} /> style={{ display: call.isRemoteVideoEnabled ? "block" : "none" }} />
{!call.isRemoteVideoEnabled && ( {!call.isRemoteVideoEnabled && (
<div className="video-placeholder"> <div className={styles.videoPlaceholder}>
<img src={defaultAvatar} alt="Avatar" className="placeholder-avatar" /> <img src={defaultAvatar} alt="Avatar" className={styles.placeholderAvatar} />
<span className="placeholder-username">{remoteUsername}</span> <span className={styles.placeholderUsername}>{remoteUsername}</span>
</div> </div>
)} )}
<div className="tile-label">{remoteUsername}</div> <div className={styles.tileLabel}>{remoteUsername}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="call-controls"> <div className={styles.callControls}>
{status === "calling" && !isInitiator ? ( {status === "calling" && !isInitiator ? (
<> <>
<MaterialIconButton onClick={acceptCall} icon="call" /> <MaterialIconButton onClick={acceptCall} icon="call" />
@@ -315,8 +262,9 @@ export function CallWindow() {
</> </>
)} )}
</div> </div>
</div> </motion.div>
)} )}
</AnimatePresence>
</>, </>,
id("root") id("root")
) )
@@ -1,10 +1,10 @@
import { MaterialButton } from "@/utils/material"; import { MaterialButton } from "@/utils/material";
import "./download-app.scss"; import styles from "./download-app.module.scss";
export default function DownloadAppPage() { export default function DownloadAppPage() {
return ( return (
<div className="download-app-screen"> <div className={styles.downloadAppScreen}>
<div className="inner"> <div>
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1> <h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
<p> <p>
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
@@ -1,4 +1,4 @@
.download-app-screen { .downloadAppScreen {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -6,3 +6,4 @@
min-height: 100vh; min-height: 100vh;
padding: 32px; padding: 32px;
} }
+66 -66
View File
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import "./home.scss"; import styles from "./home.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon } from "@/utils/material"; import { MaterialButton, MaterialIcon } from "@/utils/material";
@@ -39,15 +39,15 @@ export default function HomePage() {
); );
return ( return (
<div className="homepage"> <div className={styles.homepage}>
<header className="homepage-header"> <header className={styles.homepageHeader}>
<div className="container"> <div className={styles.container}>
<div className="header-content"> <div className={styles.headerContent}>
<div className="logo"> <div className={styles.logo}>
<h1>FromChat</h1> <h1>FromChat</h1>
<span className="tagline">100% открытый мессенджер</span> <span className={styles.tagline}>100% открытый мессенджер</span>
</div> </div>
<nav className="header-nav"> <nav className={styles.headerNav}>
<GitHubLink> <GitHubLink>
<MaterialButton variant="text">GitHub</MaterialButton> <MaterialButton variant="text">GitHub</MaterialButton>
</GitHubLink> </GitHubLink>
@@ -61,18 +61,18 @@ export default function HomePage() {
</div> </div>
</header> </header>
<main className="homepage-main"> <main>
<section className="hero"> <section className={styles.hero}>
<div className="container"> <div className={styles.container}>
<div className="hero-content"> <div className={styles.heroContent}>
<h2 className="hero-title"> <h2 className={styles.heroTitle}>
Безопасный мессенджер с открытым исходным кодом Безопасный мессенджер с открытым исходным кодом
</h2> </h2>
<p className="hero-description"> <p className={styles.heroDescription}>
FromChat это полностью открытый мессенджер с end-to-end шифрованием, FromChat это полностью открытый мессенджер с end-to-end шифрованием,
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу. поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
</p> </p>
<div className="hero-actions"> <div className={styles.heroActions}>
{openBtn} {openBtn}
{!isMobile && ( {!isMobile && (
<MaterialButton <MaterialButton
@@ -83,32 +83,32 @@ export default function HomePage() {
)} )}
</div> </div>
</div> </div>
<div className="hero-visual"> <div className={styles.heroVisual}>
<div className="chat-preview"> <div className={styles.chatPreview}>
<div className="chat-window"> <div className={styles.chatWindow}>
<div className="chat-header"> <div className={styles.chatHeader}>
<div className="chat-title">Общий чат</div> <div className={styles.chatTitle}>Общий чат</div>
<div className="online-indicator"></div> <div className={styles.onlineIndicator}></div>
</div> </div>
<div className="chat-messages"> <div className={styles.chatMessages}>
<div className="message received"> <div className={`${styles.message} ${styles.received}`}>
<div className="message-avatar">А</div> <div className={styles.messageAvatar}>А</div>
<div className="message-content"> <div className={styles.messageContent}>
<div className="message-text">Привет! Как дела?</div> <div className={styles.messageText}>Привет! Как дела?</div>
<div className="message-time">14:30</div> <div className={styles.messageTime}>14:30</div>
</div> </div>
</div> </div>
<div className="message sent"> <div className={`${styles.message} ${styles.sent}`}>
<div className="message-content"> <div className={styles.messageContent}>
<div className="message-text">Всё отлично! А у тебя как?</div> <div className={styles.messageText}>Всё отлично! А у тебя как?</div>
<div className="message-time">14:32</div> <div className={styles.messageTime}>14:32</div>
</div> </div>
</div> </div>
<div className="message received"> <div className={`${styles.message} ${styles.received}`}>
<div className="message-avatar">Б</div> <div className={styles.messageAvatar}>Б</div>
<div className="message-content"> <div className={styles.messageContent}>
<div className="message-text">Отправляю файл 📎</div> <div className={styles.messageText}>Отправляю файл 📎</div>
<div className="message-time">14:35</div> <div className={styles.messageTime}>14:35</div>
</div> </div>
</div> </div>
</div> </div>
@@ -118,12 +118,12 @@ export default function HomePage() {
</div> </div>
</section> </section>
<section className="features"> <section className={styles.features}>
<div className="container"> <div className={styles.container}>
<h3 className="section-title">Возможности</h3> <h3 className={styles.sectionTitle}>Возможности</h3>
<div className="features-grid"> <div className={styles.featuresGrid}>
<div className="feature-card"> <div className={styles.featureCard}>
<div className="feature-icon"> <div className={styles.featureIcon}>
<MaterialIcon name="security" /> <MaterialIcon name="security" />
</div> </div>
<h4>End-to-End Шифрование</h4> <h4>End-to-End Шифрование</h4>
@@ -133,8 +133,8 @@ export default function HomePage() {
</p> </p>
</div> </div>
<div className="feature-card"> <div className={styles.featureCard}>
<div className="feature-icon"> <div className={styles.featureIcon}>
<MaterialIcon name="code" /> <MaterialIcon name="code" />
</div> </div>
<h4>100% открытый код</h4> <h4>100% открытый код</h4>
@@ -144,8 +144,8 @@ export default function HomePage() {
</p> </p>
</div> </div>
<div className="feature-card"> <div className={styles.featureCard}>
<div className="feature-icon"> <div className={styles.featureIcon}>
<MaterialIcon name="attach_file" /> <MaterialIcon name="attach_file" />
</div> </div>
<h4>Обмен Файлами</h4> <h4>Обмен Файлами</h4>
@@ -155,8 +155,8 @@ export default function HomePage() {
</p> </p>
</div> </div>
<div className="feature-card"> <div className={styles.featureCard}>
<div className="feature-icon"> <div className={styles.featureIcon}>
<MaterialIcon name="notifications" /> <MaterialIcon name="notifications" />
</div> </div>
<h4>Уведомления</h4> <h4>Уведомления</h4>
@@ -166,8 +166,8 @@ export default function HomePage() {
</p> </p>
</div> </div>
<div className="feature-card"> <div className={styles.featureCard}>
<div className="feature-icon"> <div className={styles.featureIcon}>
<MaterialIcon name="edit" /> <MaterialIcon name="edit" />
</div> </div>
<h4>Редактирование</h4> <h4>Редактирование</h4>
@@ -177,8 +177,8 @@ export default function HomePage() {
</p> </p>
</div> </div>
<div className="feature-card"> <div className={styles.featureCard}>
<div className="feature-icon"> <div className={styles.featureIcon}>
<MaterialIcon name="computer" /> <MaterialIcon name="computer" />
</div> </div>
<h4>Кроссплатформенность</h4> <h4>Кроссплатформенность</h4>
@@ -191,15 +191,15 @@ export default function HomePage() {
</div> </div>
</section> </section>
<section className="download"> <section className={styles.download}>
<div className="container"> <div className={styles.container}>
<div className="download-content"> <div className={styles.downloadContent}>
<h3>Скачайте приложение</h3> <h3>Скачайте приложение</h3>
<p> <p>
Для лучшего опыта используйте настольное приложение с поддержкой Для лучшего опыта используйте настольное приложение с поддержкой
уведомлений и автономной работы. уведомлений и автономной работы.
</p> </p>
<div className="download-buttons"> <div className={styles.downloadButtons}>
{!isMobile ? ( {!isMobile ? (
<> <>
<a <a
@@ -227,14 +227,14 @@ export default function HomePage() {
</div> </div>
</section> </section>
<section className="cta"> <section className={styles.cta}>
<div className="container"> <div className={styles.container}>
<div className="cta-content"> <div className={styles.ctaContent}>
<h3>Готовы начать общение?</h3> <h3>Готовы начать общение?</h3>
<p> <p>
Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами. Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами.
</p> </p>
<div className="cta-actions"> <div className={styles.ctaActions}>
{isMobile ? ( {isMobile ? (
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}> <MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
Скачать приложение Скачать приложение
@@ -259,20 +259,20 @@ export default function HomePage() {
</section> </section>
</main> </main>
<footer className="homepage-footer"> <footer className={styles.homepageFooter}>
<div className="container"> <div className={styles.container}>
<div className="footer-content"> <div className={styles.footerContent}>
<div className="footer-section"> <div className={styles.footerSection}>
<h4>Ссылки</h4> <h4>Ссылки</h4>
<GitHubLink>GitHub</GitHubLink> <GitHubLink>GitHub</GitHubLink>
<SupportLink>Поддержка</SupportLink> <SupportLink>Поддержка</SupportLink>
</div> </div>
<div className="footer-section"> <div className={styles.footerSection}>
<h4>Лицензия</h4> <h4>Лицензия</h4>
<p>GPL-3.0</p> <p>GPL-3.0</p>
</div> </div>
</div> </div>
<div className="footer-bottom"> <div className={styles.footerBottom}>
<p>&copy; 2025 FromChat. Сделано программистом denis0001-dev с для свободы общения.</p> <p>&copy; 2025 FromChat. Сделано программистом denis0001-dev с для свободы общения.</p>
</div> </div>
</div> </div>
@@ -36,7 +36,7 @@
} }
// Header styles // Header styles
.homepage-header { .homepageHeader {
padding: 1rem 0; padding: 1rem 0;
background: rgba($color-dark-surface-container, 0.8); background: rgba($color-dark-surface-container, 0.8);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
@@ -47,7 +47,7 @@
z-index: 1000; z-index: 1000;
transition: all 0.3s ease; transition: all 0.3s ease;
.header-content { .headerContent {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -69,7 +69,7 @@
} }
} }
.header-nav { .headerNav {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@@ -89,11 +89,11 @@
min-height: 80vh; min-height: 80vh;
margin-top: 0; margin-top: 0;
.hero-content { .heroContent {
flex: 1; flex: 1;
max-width: 600px; max-width: 600px;
.hero-title { .heroTitle {
font-size: 3.5rem; font-size: 3.5rem;
font-weight: 800; font-weight: 800;
line-height: 1.1; line-height: 1.1;
@@ -106,31 +106,31 @@
animation: neonGlow 3s ease-in-out infinite alternate; animation: neonGlow 3s ease-in-out infinite alternate;
} }
.hero-description { .heroDescription {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.6; line-height: 1.6;
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
opacity: 0.9; opacity: 0.9;
} }
.hero-actions { .heroActions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
.hero-visual { .heroVisual {
flex: 1; flex: 1;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 2rem; padding: 2rem;
.chat-preview { .chatPreview {
perspective: 1000px; perspective: 1000px;
.chat-window { .chatWindow {
background: rgba($color-dark-surface-container, 0.95); background: rgba($color-dark-surface-container, 0.95);
border-radius: 20px; border-radius: 20px;
padding: 1.5rem; padding: 1.5rem;
@@ -144,7 +144,7 @@
width: 100%; width: 100%;
border: 1px solid rgba($color-dark-primary, 0.3); border: 1px solid rgba($color-dark-primary, 0.3);
.chat-header { .chatHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -152,12 +152,12 @@
border-bottom: 1px solid rgba($color-dark-primary, 0.3); border-bottom: 1px solid rgba($color-dark-primary, 0.3);
margin-bottom: 1rem; margin-bottom: 1rem;
.chat-title { .chatTitle {
font-weight: 600; font-weight: 600;
font-size: 1.1rem; font-size: 1.1rem;
} }
.online-indicator { .onlineIndicator {
color: $color-dark-primary; color: $color-dark-primary;
font-size: 0.8rem; font-size: 0.8rem;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.8); text-shadow: 0 0 10px rgba($color-dark-primary, 0.8);
@@ -165,7 +165,7 @@
} }
} }
.chat-messages { .chatMessages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -178,7 +178,7 @@
&.sent { &.sent {
flex-direction: row-reverse; flex-direction: row-reverse;
.message-content { .messageContent {
background: linear-gradient(135deg, $color-dark-primary, $color-dark-primary-container); background: linear-gradient(135deg, $color-dark-primary, $color-dark-primary-container);
color: $color-dark-on-primary; color: $color-dark-on-primary;
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3); box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
@@ -186,14 +186,14 @@
} }
&.received { &.received {
.message-content { .messageContent {
background: rgba($color-dark-surface-variant, 0.8); background: rgba($color-dark-surface-variant, 0.8);
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
border: 1px solid rgba($color-dark-outline-variant, 0.3); border: 1px solid rgba($color-dark-outline-variant, 0.3);
} }
} }
.message-avatar { .messageAvatar {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
@@ -208,18 +208,18 @@
box-shadow: 0 0 10px rgba($color-dark-primary, 0.4); box-shadow: 0 0 10px rgba($color-dark-primary, 0.4);
} }
.message-content { .messageContent {
max-width: 70%; max-width: 70%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: 18px; border-radius: 18px;
position: relative; position: relative;
.message-text { .messageText {
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.4; line-height: 1.4;
} }
.message-time { .messageTime {
font-size: 0.75rem; font-size: 0.75rem;
opacity: 0.7; opacity: 0.7;
margin-top: 0.25rem; margin-top: 0.25rem;
@@ -240,7 +240,7 @@
border-top: 1px solid rgba($color-dark-primary, 0.2); border-top: 1px solid rgba($color-dark-primary, 0.2);
border-bottom: 1px solid rgba($color-dark-primary, 0.2); border-bottom: 1px solid rgba($color-dark-primary, 0.2);
.section-title { .sectionTitle {
text-align: center; text-align: center;
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;
@@ -252,12 +252,12 @@
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
} }
.features-grid { .featuresGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem; gap: 2rem;
.feature-card { .featureCard {
background: rgba($color-dark-surface-container, 0.6); background: rgba($color-dark-surface-container, 0.6);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border-radius: 20px; border-radius: 20px;
@@ -297,7 +297,7 @@
} }
} }
.feature-icon { .featureIcon {
width: 60px; width: 60px;
height: 60px; height: 60px;
background: linear-gradient(135deg, rgba($color-dark-primary, 0.3), rgba($color-dark-tertiary, 0.3)); background: linear-gradient(135deg, rgba($color-dark-primary, 0.3), rgba($color-dark-tertiary, 0.3));
@@ -338,7 +338,7 @@
.download { .download {
padding: 6rem 0; padding: 6rem 0;
.download-content { .downloadContent {
text-align: center; text-align: center;
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
@@ -361,7 +361,7 @@
opacity: 0.9; opacity: 0.9;
} }
.download-buttons { .downloadButtons {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
justify-content: center; justify-content: center;
@@ -377,7 +377,7 @@
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border-top: 1px solid rgba($color-dark-primary, 0.2); border-top: 1px solid rgba($color-dark-primary, 0.2);
.cta-content { .ctaContent {
text-align: center; text-align: center;
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
@@ -400,7 +400,7 @@
opacity: 0.9; opacity: 0.9;
} }
.cta-actions { .ctaActions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
justify-content: center; justify-content: center;
@@ -410,20 +410,20 @@
} }
// Footer // Footer
.homepage-footer { .homepageFooter {
background: rgba($color-dark-surface-container, 0.8); background: rgba($color-dark-surface-container, 0.8);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
padding: 3rem 0 1rem; padding: 3rem 0 1rem;
border-top: 1px solid rgba($color-dark-primary, 0.3); border-top: 1px solid rgba($color-dark-primary, 0.3);
box-shadow: 0 -4px 20px rgba($color-dark-primary, 0.1); box-shadow: 0 -4px 20px rgba($color-dark-primary, 0.1);
.footer-content { .footerContent {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem; gap: 2rem;
margin-bottom: 2rem; margin-bottom: 2rem;
.footer-section { .footerSection {
h4 { h4 {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
@@ -458,7 +458,7 @@
} }
} }
.footer-bottom { .footerBottom {
text-align: center; text-align: center;
padding-top: 2rem; padding-top: 2rem;
border-top: 1px solid rgba($color-dark-primary, 0.3); border-top: 1px solid rgba($color-dark-primary, 0.3);
@@ -494,7 +494,7 @@
// Responsive Design // Responsive Design
@media (min-width: 1024px) { @media (min-width: 1024px) {
.homepage { .homepage {
.homepage-header { .homepageHeader {
position: fixed; position: fixed;
top: 1rem; top: 1rem;
left: 50%; left: 50%;
@@ -518,7 +518,7 @@
@media (max-width: 1023px) { @media (max-width: 1023px) {
.homepage { .homepage {
.homepage-header { .homepageHeader {
position: sticky; position: sticky;
top: 0; top: 0;
border-radius: 0; border-radius: 0;
@@ -535,8 +535,8 @@
padding: 0 1rem; padding: 0 1rem;
} }
.homepage-header { .homepageHeader {
.header-content { .headerContent {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
@@ -547,21 +547,21 @@
text-align: center; text-align: center;
padding: 2rem 0; padding: 2rem 0;
.hero-content { .heroContent {
.hero-title { .heroTitle {
font-size: 2.5rem; font-size: 2.5rem;
} }
.hero-description { .heroDescription {
font-size: 1.1rem; font-size: 1.1rem;
} }
} }
.hero-visual { .heroVisual {
padding: 1rem; padding: 1rem;
.chat-preview { .chatPreview {
.chat-window { .chatWindow {
transform: none; transform: none;
max-width: 100%; max-width: 100%;
} }
@@ -570,26 +570,26 @@
} }
.features { .features {
.features-grid { .featuresGrid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
.feature-card { .featureCard {
padding: 1.5rem; padding: 1.5rem;
} }
} }
.section-title { .sectionTitle {
font-size: 2rem; font-size: 2rem;
} }
} }
.download { .download {
.download-content { .downloadContent {
h3 { h3 {
font-size: 2rem; font-size: 2rem;
} }
.download-buttons { .downloadButtons {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
@@ -597,20 +597,20 @@
} }
.cta { .cta {
.cta-content { .ctaContent {
h3 { h3 {
font-size: 2rem; font-size: 2rem;
} }
.cta-actions { .ctaActions {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
} }
} }
.homepage-footer { .homepageFooter {
.footer-content { .footerContent {
grid-template-columns: 1fr; grid-template-columns: 1fr;
text-align: center; text-align: center;
} }
@@ -621,25 +621,25 @@
@media (max-width: 480px) { @media (max-width: 480px) {
.homepage { .homepage {
.hero { .hero {
.hero-content { .heroContent {
.hero-title { .heroTitle {
font-size: 2rem; font-size: 2rem;
} }
.hero-description { .heroDescription {
font-size: 1rem; font-size: 1rem;
} }
} }
} }
.features { .features {
.section-title { .sectionTitle {
font-size: 1.75rem; font-size: 1.75rem;
} }
} }
.download { .download {
.download-content { .downloadContent {
h3 { h3 {
font-size: 1.75rem; font-size: 1.75rem;
} }
@@ -647,7 +647,7 @@
} }
.cta { .cta {
.cta-content { .ctaContent {
h3 { h3 {
font-size: 1.75rem; font-size: 1.75rem;
} }
@@ -655,3 +655,4 @@
} }
} }
} }
@@ -1,20 +1,20 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./not-found.scss"; import styles from "./not-found.module.scss";
import { MaterialButton, MaterialIcon } from "@/utils/material"; import { MaterialButton, MaterialIcon } from "@/utils/material";
export default function NotFoundPage() { export default function NotFoundPage() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className="not-found-page"> <div className={styles.notFoundPage}>
<div className="not-found-container"> <div className={styles.notFoundContainer}>
<div className="not-found-content"> <div className={styles.notFoundContent}>
<div className="error-code">404</div> <div className={styles.errorCode}>404</div>
<h1>Страница не найдена</h1> <h1>Страница не найдена</h1>
<p> <p>
К сожалению, запрашиваемая страница не существует или была перемещена. К сожалению, запрашиваемая страница не существует или была перемещена.
</p> </p>
<div className="not-found-actions"> <div className={styles.notFoundActions}>
<MaterialButton <MaterialButton
variant="filled" variant="filled"
onClick={() => navigate("/")} onClick={() => navigate("/")}
@@ -29,7 +29,7 @@ export default function NotFoundPage() {
</MaterialButton> </MaterialButton>
</div> </div>
</div> </div>
<div className="not-found-illustration"> <div className={styles.notFoundIllustration}>
<MaterialIcon name="search_off" /> <MaterialIcon name="search_off" />
</div> </div>
</div> </div>
@@ -1,4 +1,4 @@
.not-found-page { .notFoundPage {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -7,7 +7,7 @@
padding: 2rem; padding: 2rem;
} }
.not-found-container { .notFoundContainer {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 3rem; gap: 3rem;
@@ -20,11 +20,26 @@
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.not-found-content { .notFoundContent {
flex: 1; flex: 1;
h1 {
font-size: 2.5rem;
font-weight: 700;
color: #2d3748;
margin-bottom: 1rem;
line-height: 1.2;
}
p {
font-size: 1.1rem;
color: #718096;
margin-bottom: 2rem;
line-height: 1.6;
}
} }
.error-code { .errorCode {
font-size: 6rem; font-size: 6rem;
font-weight: 900; font-weight: 900;
color: #667eea; color: #667eea;
@@ -33,28 +48,13 @@
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
} }
.not-found-content h1 { .notFoundActions {
font-size: 2.5rem;
font-weight: 700;
color: #2d3748;
margin-bottom: 1rem;
line-height: 1.2;
}
.not-found-content p {
font-size: 1.1rem;
color: #718096;
margin-bottom: 2rem;
line-height: 1.6;
}
.not-found-actions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.not-found-illustration { .notFoundIllustration {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -63,22 +63,25 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.not-found-container { .notFoundContainer {
flex-direction: column; flex-direction: column;
text-align: center; text-align: center;
gap: 2rem; gap: 2rem;
padding: 2rem; padding: 2rem;
} }
.error-code { .errorCode {
font-size: 4rem; font-size: 4rem;
} }
.not-found-content h1 { .notFoundContent {
font-size: 2rem; h1 {
font-size: 2rem;
}
} }
.not-found-actions { .notFoundActions {
justify-content: center; justify-content: center;
} }
} }
+4
View File
@@ -5,11 +5,15 @@ import electron from "vite-plugin-electron/simple";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "path"; import path from "path";
import { visualizer } from 'rollup-plugin-visualizer'; import { visualizer } from 'rollup-plugin-visualizer';
import sassDts from 'vite-plugin-sass-dts';
const outDir = process.env.VITE_ELECTRON ? "build/electron" : "build/normal"; const outDir = process.env.VITE_ELECTRON ? "build/electron" : "build/normal";
const plugins: PluginOption[] = [ const plugins: PluginOption[] = [
react(), react(),
sassDts({
enabledMode: ['development', 'production']
}),
createHtmlPlugin({ createHtmlPlugin({
minify: { minify: {
collapseWhitespace: true, collapseWhitespace: true,
+2 -1
View File
@@ -60,7 +60,8 @@
"vite": "^7.1.6", "vite": "^7.1.6",
"vite-plugin-electron": "^0.29.0", "vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6", "vite-plugin-electron-renderer": "^0.14.6",
"vite-plugin-html": "^3.2.2" "vite-plugin-html": "^3.2.2",
"vite-plugin-sass-dts": "^1.3.34"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.2.7", "dompurify": "^3.2.7",