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