From 0921ec875c6da3670a41110024ba53c3236aaf02 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 1 Nov 2025 19:29:36 +0300 Subject: [PATCH] Migrate to SCSS modules --- .gitignore | 3 +- .vscode/settings.json | 8 +- frontend/src/core/components/SearchBar.tsx | 20 +- frontend/src/core/components/StyledDialog.tsx | 9 +- .../{searchBar.scss => searchBar.module.scss} | 56 +--- ...-dialog.scss => styled-dialog.module.scss} | 10 +- frontend/src/css/style.scss | 2 - frontend/src/pages/auth/Auth.tsx | 7 +- frontend/src/pages/auth/LoginPage.tsx | 6 +- frontend/src/pages/auth/RegisterPage.tsx | 8 +- .../auth/{auth.scss => auth.module.scss} | 12 +- .../src/pages/chat/css/ChatInput.module.scss | 141 +++++++++ ..._chat-input.scss => EmojiMenu.module.scss} | 141 +-------- .../{_message.scss => Message.module.scss} | 174 ++++++++--- ...nu.scss => MessageContextMenu.module.scss} | 284 ++++++++++++++++-- ...tors.scss => TypingIndicators.module.scss} | 31 +- frontend/src/pages/chat/css/_left-panel.scss | 249 --------------- .../pages/chat/css/_message-reactions.scss | 59 ---- .../src/pages/chat/css/_profile-dialog.scss | 205 ------------- ...callWindow.scss => callWindow.module.scss} | 218 ++++++-------- ....scss => changePasswordDialog.module.scss} | 12 +- frontend/src/pages/chat/css/chat.scss | 15 - .../src/pages/chat/css/cropper-dialog.scss | 51 ---- .../css/{_layout.scss => layout.module.scss} | 27 +- .../src/pages/chat/css/left-panel.module.scss | 204 +++++++++++++ .../pages/chat/css/profile-dialog.module.scss | 209 +++++++++++++ ...ght-panel.scss => right-panel.module.scss} | 83 +++-- ...ialog.scss => settings-dialog.module.scss} | 47 +-- ...log.scss => suspension-dialog.module.scss} | 18 +- frontend/src/pages/chat/state.ts | 54 ++-- frontend/src/pages/chat/ui/ChatPage.tsx | 6 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 45 +-- .../src/pages/chat/ui/SuspensionDialog.tsx | 19 +- .../src/pages/chat/ui/left/ChatHeader.tsx | 7 +- frontend/src/pages/chat/ui/left/ChatTabs.tsx | 3 +- frontend/src/pages/chat/ui/left/LeftPanel.tsx | 5 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 5 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 106 ++++--- .../chat/ui/left/profile/CropperDialog.tsx | 22 -- .../chat/ui/left/profile/ImageCropper.tsx | 193 ------------ .../ui/left/settings/ChangePasswordDialog.tsx | 13 +- .../chat/ui/left/settings/SettingsDialog.tsx | 25 +- .../pages/chat/ui/right/ChatInputWrapper.tsx | 45 +-- .../src/pages/chat/ui/right/ChatMessages.tsx | 54 +--- .../src/pages/chat/ui/right/EmojiMenu.tsx | 42 +-- frontend/src/pages/chat/ui/right/Message.tsx | 57 ++-- .../chat/ui/right/MessageContextMenu.tsx | 48 ++- .../chat/ui/right/MessagePanelRenderer.tsx | 68 ++--- .../pages/chat/ui/right/OnlineIndicator.tsx | 5 +- .../src/pages/chat/ui/right/OnlineStatus.tsx | 7 +- .../pages/chat/ui/right/TypingIndicator.tsx | 13 +- .../pages/chat/ui/right/calls/CallWindow.tsx | 204 +++++-------- .../pages/download-app/DownloadAppPage.tsx | 6 +- ...load-app.scss => download-app.module.scss} | 5 +- frontend/src/pages/home/HomePage.tsx | 132 ++++---- .../home/{home.scss => home.module.scss} | 117 ++++---- frontend/src/pages/not-found/NotFoundPage.tsx | 14 +- .../{not-found.scss => not-found.module.scss} | 55 ++-- frontend/vite.config.ts | 4 + package.json | 3 +- 60 files changed, 1802 insertions(+), 1889 deletions(-) rename frontend/src/core/components/css/{searchBar.scss => searchBar.module.scss} (70%) rename frontend/src/core/components/css/{_styled-dialog.scss => styled-dialog.module.scss} (89%) rename frontend/src/pages/auth/{auth.scss => auth.module.scss} (89%) create mode 100644 frontend/src/pages/chat/css/ChatInput.module.scss rename frontend/src/pages/chat/css/{_chat-input.scss => EmojiMenu.module.scss} (57%) rename frontend/src/pages/chat/css/{_message.scss => Message.module.scss} (75%) rename frontend/src/pages/chat/css/{_context-menu.scss => MessageContextMenu.module.scss} (55%) rename frontend/src/pages/chat/css/{_typing-indicators.scss => TypingIndicators.module.scss} (83%) delete mode 100644 frontend/src/pages/chat/css/_left-panel.scss delete mode 100644 frontend/src/pages/chat/css/_message-reactions.scss delete mode 100644 frontend/src/pages/chat/css/_profile-dialog.scss rename frontend/src/pages/chat/css/{_callWindow.scss => callWindow.module.scss} (74%) rename frontend/src/pages/chat/css/{_changePasswordDialog.scss => changePasswordDialog.module.scss} (78%) delete mode 100644 frontend/src/pages/chat/css/chat.scss delete mode 100644 frontend/src/pages/chat/css/cropper-dialog.scss rename frontend/src/pages/chat/css/{_layout.scss => layout.module.scss} (75%) create mode 100644 frontend/src/pages/chat/css/left-panel.module.scss create mode 100644 frontend/src/pages/chat/css/profile-dialog.module.scss rename frontend/src/pages/chat/css/{_right-panel.scss => right-panel.module.scss} (68%) rename frontend/src/pages/chat/css/{_settings-dialog.scss => settings-dialog.module.scss} (73%) rename frontend/src/pages/chat/css/{_suspension-dialog.scss => suspension-dialog.module.scss} (84%) delete mode 100644 frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx delete mode 100644 frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx rename frontend/src/pages/download-app/{download-app.scss => download-app.module.scss} (85%) rename frontend/src/pages/home/{home.scss => home.module.scss} (90%) rename frontend/src/pages/not-found/{not-found.scss => not-found.module.scss} (66%) diff --git a/.gitignore b/.gitignore index 096357a..4510a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -573,4 +573,5 @@ package-lock.json backend/alembic/** !backend/alembic/env.py !backend/alembic/script.py.mako -!frontend/src/css/lib \ No newline at end of file +!frontend/src/css/lib +**/*.module.scss.d.ts \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 16545c5..3c122bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,7 @@ { "files.exclude": { "**/__pycache__": true, - "**/package-lock.json": true - }, - "github-actions.workflows.pinned.workflows": [], - "github-actions.workflows.pinned.workflows.ignore": true, - "github-actions.workflows.pinned.workflows.ignoreContextAccess": true + "**/package-lock.json": true, + "**/*.module.scss.d.ts": true + } } \ No newline at end of file diff --git a/frontend/src/core/components/SearchBar.tsx b/frontend/src/core/components/SearchBar.tsx index ae97265..8616475 100644 --- a/frontend/src/core/components/SearchBar.tsx +++ b/frontend/src/core/components/SearchBar.tsx @@ -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 (
{/* Single Search Bar Element */} -
+
{/* Left Icon */} -
+
{renderIcon(leftIcon, "search--outlined")}
{/* Input/Placeholder */} -
+
{isExpanded ? ( e.stopPropagation()} /> ) : ( - {placeholder} + {placeholder} )}
{/* Right Icon */} -
+
{renderIcon(rightIcon)}
{/* Results Section - Only visible when expanded */} {isExpanded && ( -
+
{children}
)} diff --git a/frontend/src/core/components/StyledDialog.tsx b/frontend/src/core/components/StyledDialog.tsx index 7fc2da8..fa87310 100644 --- a/frontend/src/core/components/StyledDialog.tsx +++ b/frontend/src/core/components/StyledDialog.tsx @@ -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({ {open && ( { if (e.target === e.currentTarget) { if (onBackdropClick) { @@ -54,12 +57,12 @@ export function StyledDialog({ exit={{ opacity: 0 }} transition={transition}> -
+
{children}
{afterChildren} diff --git a/frontend/src/core/components/css/searchBar.scss b/frontend/src/core/components/css/searchBar.module.scss similarity index 70% rename from frontend/src/core/components/css/searchBar.scss rename to frontend/src/core/components/css/searchBar.module.scss index 2d732e5..a63d859 100644 --- a/frontend/src/core/components/css/searchBar.scss +++ b/frontend/src/core/components/css/searchBar.module.scss @@ -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; - } - } } } + diff --git a/frontend/src/core/components/css/_styled-dialog.scss b/frontend/src/core/components/css/styled-dialog.module.scss similarity index 89% rename from frontend/src/core/components/css/_styled-dialog.scss rename to frontend/src/core/components/css/styled-dialog.module.scss index 1a73f1a..a825eab 100644 --- a/frontend/src/core/components/css/_styled-dialog.scss +++ b/frontend/src/core/components/css/styled-dialog.module.scss @@ -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; diff --git a/frontend/src/css/style.scss b/frontend/src/css/style.scss index 41491d5..2aabfc6 100644 --- a/frontend/src/css/style.scss +++ b/frontend/src/css/style.scss @@ -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; diff --git a/frontend/src/pages/auth/Auth.tsx b/frontend/src/pages/auth/Auth.tsx index 65c30b5..4a24ebd 100644 --- a/frontend/src/pages/auth/Auth.tsx +++ b/frontend/src/pages/auth/Auth.tsx @@ -1,9 +1,10 @@ import type React from "react"; +import styles from "./auth.module.scss"; export function AuthContainer({ children }: { children?: React.ReactNode }) { return ( -
-
+
+
{children}
@@ -28,7 +29,7 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) { const iconName = typeof icon == "string" ? icon : icon.name; return ( -
+

{iconName} {title} diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index ad6de7a..15e8c60 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -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 ( -
+
-
+
{ @@ -113,7 +113,6 @@ export default function RegisterPage() { }}> .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 + diff --git a/frontend/src/pages/chat/css/_chat-input.scss b/frontend/src/pages/chat/css/EmojiMenu.module.scss similarity index 57% rename from frontend/src/pages/chat/css/_chat-input.scss rename to frontend/src/pages/chat/css/EmojiMenu.module.scss index 9be72b7..2ff83dc 100644 --- a/frontend/src/pages/chat/css/_chat-input.scss +++ b/frontend/src/pages/chat/css/EmojiMenu.module.scss @@ -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; } -} +} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/_message.scss b/frontend/src/pages/chat/css/Message.module.scss similarity index 75% rename from frontend/src/pages/chat/css/_message.scss rename to frontend/src/pages/chat/css/Message.module.scss index b316d2d..6b61916 100644 --- a/frontend/src/pages/chat/css/_message.scss +++ b/frontend/src/pages/chat/css/Message.module.scss @@ -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); + } +} + diff --git a/frontend/src/pages/chat/css/_context-menu.scss b/frontend/src/pages/chat/css/MessageContextMenu.module.scss similarity index 55% rename from frontend/src/pages/chat/css/_context-menu.scss rename to frontend/src/pages/chat/css/MessageContextMenu.module.scss index e3380e0..f8701b7 100644 --- a/frontend/src/pages/chat/css/_context-menu.scss +++ b/frontend/src/pages/chat/css/MessageContextMenu.module.scss @@ -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); + } +} + diff --git a/frontend/src/pages/chat/css/_typing-indicators.scss b/frontend/src/pages/chat/css/TypingIndicators.module.scss similarity index 83% rename from frontend/src/pages/chat/css/_typing-indicators.scss rename to frontend/src/pages/chat/css/TypingIndicators.module.scss index ddc2850..c7dfbff 100644 --- a/frontend/src/pages/chat/css/_typing-indicators.scss +++ b/frontend/src/pages/chat/css/TypingIndicators.module.scss @@ -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; } } + diff --git a/frontend/src/pages/chat/css/_left-panel.scss b/frontend/src/pages/chat/css/_left-panel.scss deleted file mode 100644 index 06b5ab8..0000000 --- a/frontend/src/pages/chat/css/_left-panel.scss +++ /dev/null @@ -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; -} diff --git a/frontend/src/pages/chat/css/_message-reactions.scss b/frontend/src/pages/chat/css/_message-reactions.scss deleted file mode 100644 index 35aaa51..0000000 --- a/frontend/src/pages/chat/css/_message-reactions.scss +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/_profile-dialog.scss b/frontend/src/pages/chat/css/_profile-dialog.scss deleted file mode 100644 index 3118de4..0000000 --- a/frontend/src/pages/chat/css/_profile-dialog.scss +++ /dev/null @@ -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%; - } -} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/_callWindow.scss b/frontend/src/pages/chat/css/callWindow.module.scss similarity index 74% rename from frontend/src/pages/chat/css/_callWindow.scss rename to frontend/src/pages/chat/css/callWindow.module.scss index 8541476..27ec968 100644 --- a/frontend/src/pages/chat/css/_callWindow.scss +++ b/frontend/src/pages/chat/css/callWindow.module.scss @@ -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; } } + diff --git a/frontend/src/pages/chat/css/_changePasswordDialog.scss b/frontend/src/pages/chat/css/changePasswordDialog.module.scss similarity index 78% rename from frontend/src/pages/chat/css/_changePasswordDialog.scss rename to frontend/src/pages/chat/css/changePasswordDialog.module.scss index a851c71..7bea088 100644 --- a/frontend/src/pages/chat/css/_changePasswordDialog.scss +++ b/frontend/src/pages/chat/css/changePasswordDialog.module.scss @@ -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; diff --git a/frontend/src/pages/chat/css/chat.scss b/frontend/src/pages/chat/css/chat.scss deleted file mode 100644 index 31bf234..0000000 --- a/frontend/src/pages/chat/css/chat.scss +++ /dev/null @@ -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"; \ No newline at end of file diff --git a/frontend/src/pages/chat/css/cropper-dialog.scss b/frontend/src/pages/chat/css/cropper-dialog.scss deleted file mode 100644 index 6cf8262..0000000 --- a/frontend/src/pages/chat/css/cropper-dialog.scss +++ /dev/null @@ -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; - } -} diff --git a/frontend/src/pages/chat/css/_layout.scss b/frontend/src/pages/chat/css/layout.module.scss similarity index 75% rename from frontend/src/pages/chat/css/_layout.scss rename to frontend/src/pages/chat/css/layout.module.scss index 07184c5..b07ecfb 100644 --- a/frontend/src/pages/chat/css/_layout.scss +++ b/frontend/src/pages/chat/css/layout.module.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss new file mode 100644 index 0000000..d0e998d --- /dev/null +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -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; +} diff --git a/frontend/src/pages/chat/css/profile-dialog.module.scss b/frontend/src/pages/chat/css/profile-dialog.module.scss new file mode 100644 index 0000000..e10b4c9 --- /dev/null +++ b/frontend/src/pages/chat/css/profile-dialog.module.scss @@ -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; +} + diff --git a/frontend/src/pages/chat/css/_right-panel.scss b/frontend/src/pages/chat/css/right-panel.module.scss similarity index 68% rename from frontend/src/pages/chat/css/_right-panel.scss rename to frontend/src/pages/chat/css/right-panel.module.scss index 8230043..ffbd68f 100644 --- a/frontend/src/pages/chat/css/_right-panel.scss +++ b/frontend/src/pages/chat/css/right-panel.module.scss @@ -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; } } } } -} \ No newline at end of file +} + diff --git a/frontend/src/pages/chat/css/_settings-dialog.scss b/frontend/src/pages/chat/css/settings-dialog.module.scss similarity index 73% rename from frontend/src/pages/chat/css/_settings-dialog.scss rename to frontend/src/pages/chat/css/settings-dialog.module.scss index e025850..5822854 100644 --- a/frontend/src/pages/chat/css/_settings-dialog.scss +++ b/frontend/src/pages/chat/css/settings-dialog.module.scss @@ -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; + } } } } diff --git a/frontend/src/pages/chat/css/_suspension-dialog.scss b/frontend/src/pages/chat/css/suspension-dialog.module.scss similarity index 84% rename from frontend/src/pages/chat/css/_suspension-dialog.scss rename to frontend/src/pages/chat/css/suspension-dialog.module.scss index ef845c8..31f3321 100644 --- a/frontend/src/pages/chat/css/_suspension-dialog.scss +++ b/frontend/src/pages/chat/css/suspension-dialog.module.scss @@ -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; diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 14577b9..52679f4 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -372,12 +372,19 @@ export const useAppState = create((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((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(); diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 6bfe5c8..0a67f6f 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -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 ( -
-
+
+
diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 1025fd7..b576348 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -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 = ( onChange(e.target.value)} @@ -51,17 +52,17 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol ); } } else { - valueComponent = {value} + valueComponent = {value} } return ( -
+
-
- +
+ {valueComponent} {error && ( -
{error}
+
{error}
)}
@@ -440,20 +441,20 @@ export function ProfileDialog() { } }} onBackdropClick={handleClose} - className="profile-dialog" + contentClassName={styles.profileDialogContent} afterChildren={ currentData.isOwnProfile && ( ) } > -
+
Profile Picture { @@ -463,7 +464,7 @@ export function ProfileDialog() { {currentData.isOwnProfile && (
@@ -471,11 +472,11 @@ export function ProfileDialog() { )}
-
-
+
+
{errors.display_name && ( -
{errors.display_name}
+
{errors.display_name}
)}
{(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && ( -
+
)} {/* Admin Actions Section - Hide for deleted users */} {!currentData.isOwnProfile && user.currentUser?.id === 1 && !currentData.deleted && ( -
-

Admin Actions

-
+
+

Admin Actions

+
+
+
-
-
- +
+
+
-
-

Аккаунт заблокирован

-

+

+

Аккаунт заблокирован

+

Ваш аккаунт был заблокирован за нарушение правил сообщества. Вы не можете отправлять сообщения или взаимодействовать с другими пользователями.

{reason && reason !== "No reason provided" && ( -
+
Причина блокировки: -
+
{reason}
)} -

+

Если вы считаете, что блокировка была применена по ошибке, обратитесь к администратору для рассмотрения вашего случая.

diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index ed69d72..dac200b 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -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 ( <> -
-
{PRODUCT_NAME}
-
+
+
{PRODUCT_NAME}
+
+
+
-
+
diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index caa11fb..3a4f709 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -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) && ( - + {formatPublicChatMessage(chat.id)} )} @@ -272,7 +273,7 @@ export function UnifiedChatsList() { size="small" />
- + {chat.lastMessage || "Нет сообщений"}
diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index 6ca3389..b27c861 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -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([]); const [isSearching, setIsSearching] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [debounceTimeout, setDebounceTimeout] = useState(null); + const switchingToUserIdRef = useRef(null); + const previousSearchResultIdsRef = useRef>(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 && ( -
+
Поиск...
)} {!isSearching && searchQuery.length >= 2 && searchResults.length === 0 && ( -
+
Пользователи не найдены
)} @@ -155,30 +188,33 @@ export function UsernameSearch() { onClick={() => handleUserClick(searchUser)} style={{ cursor: "pointer" }} > -
- {searchUser.username} - -
-
- {searchUser.username} { - e.target.src = defaultAvatar; - }} - /> - +
+
+
+ {searchUser.username} { + e.target.src = defaultAvatar; + }} + /> + +
+
+
+ {searchUser.username} + +
+
+ +
+
+
))} @@ -186,7 +222,7 @@ export function UsernameSearch() { )} {!isSearching && searchQuery.length < 2 && ( -
+
Введите минимум 2 символа для поиска
)} diff --git a/frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx b/frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx deleted file mode 100644 index e3f2c31..0000000 --- a/frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { MaterialButton, MaterialIconButton } from "@/utils/material"; -import "./css/cropper-dialog.scss"; - -export function CropperDialog() { - return ( - -
-
-

Обрезать фото профиля

- -
-
-
-
-
- Отмена - Сохранить -
-
-
- ); -} diff --git a/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx b/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx deleted file mode 100644 index d0da4b4..0000000 --- a/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx +++ /dev/null @@ -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(null); - const imageRef = useRef(null); - const [src, setSrc] = useState(undefined); - const [isLoaded, setIsLoaded] = useState(false); - const [cropArea, setCropArea] = useState({ x: 0, y: 0, width: 200, height: 200 }); - const [isDragging, setIsDragging] = useState(false); - const [dragStart, setDragStart] = useState({ 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 ( -
- - Crop source -
- - Обрезать - - - Отмена - -
-
- ); -} diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx index a7c814a..8b6f9f1 100644 --- a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx @@ -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 ( -
-
+
+
onOpenChange(false)}> -
Изменить пароль
+
Изменить пароль
-
+
{ 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 /> -
+
setLogoutAll(e.target.checked)} />
-
+
Сохранить
diff --git a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx index 8e9c461..091a787 100644 --- a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx @@ -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 ( <> - -
-
- onOpenChange(false)}> -
Настройки
+ +
+
+ onOpenChange(false)}> +
Настройки
-
+
-
-
+
+

Уведомления

{pushSupported && ( -
+

Безопасность

setCpOpen(true)}>Изменить пароль
-
+

Устройства

{ if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах @@ -166,10 +167,10 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
-
+

О приложении

100% open source. Репозиторий на GitHub.

-

{PRODUCT_NAME}

+

{PRODUCT_NAME}

diff --git a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx index 319a54c..c7e62aa 100644 --- a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx +++ b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx @@ -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 ( -
-
+
+ {editVisible && editingMessage && ( -
+
- - {editingMessage!.username} - {editingMessage!.content} + + {editingMessage!.username} + {editingMessage!.content} - +
)} @@ -175,13 +176,13 @@ export function ChatInputWrapper( transition={{ duration: 0.25 }} style={{ overflow: "hidden" }} > -
+
- - {replyTo!.username} - {replyTo!.content} + + {replyTo!.username} + {replyTo!.content} - +
)} @@ -195,9 +196,9 @@ export function ChatInputWrapper( transition={{ duration: 0.25 }} style={{ overflow: "hidden" }} > -
+
-
+
{selectedFiles.map((file, i) => ( ))}
- setAttachmentsVisible(false)}> + setAttachmentsVisible(false)}>
)} -
-
+
+
e.stopPropagation()} onMouseUp={e => e.stopPropagation()} - className="emoji-btn" /> + className={styles.emojiBtn} />
-
- -
diff --git a/frontend/src/pages/chat/ui/right/ChatMessages.tsx b/frontend/src/pages/chat/ui/right/ChatMessages.tsx index 4373522..8447153 100644 --- a/frontend/src/pages/chat/ui/right/ChatMessages.tsx +++ b/frontend/src/pages/chat/ui/right/ChatMessages.tsx @@ -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({ 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 ( <> -
+
{messages.map((message: MessageType) => ( - - setDeleteDialogOpen(false)}>Отменить - Удалить - - {/* Context Menu */} {contextMenu.message && ( )} - - ); } diff --git a/frontend/src/pages/chat/ui/right/EmojiMenu.tsx b/frontend/src/pages/chat/ui/right/EmojiMenu.tsx index ebc5898..4a38dad 100644 --- a/frontend/src/pages/chat/ui/right/EmojiMenu.tsx +++ b/frontend/src/pages/chat/ui/right/EmojiMenu.tsx @@ -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 (
-
-
+ > +
+
{EMOJI_CATEGORIES.map((category) => (
) : ( -
+
No {category.name} emojis
)} diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 0be90d6..9423457 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -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 ( -
+
{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 ( ); })} @@ -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 `${match}`; + return `${match}`; }); // 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 ( <>
{!isAuthor && !isDm && ( -
+
{message.username} )} -
+
{!isAuthor && !isDm && !isSingleEmojiMessage && (
{message.username} - {message.reply_to.username} - {message.reply_to.content} + + {message.reply_to.username} + {message.reply_to.content} )}
{message.files && message.files.length > 0 && ( - + {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 ( -
+
{isImage ? ( -
+
{ 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) && ( -
+
)} @@ -554,7 +555,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD }} > - + {isDownloading ? : null} {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} @@ -573,7 +574,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD messageId={message.id} /> -
+
{formatTime(message.timestamp)} {message.is_edited ? " (edited)" : undefined} @@ -582,15 +583,15 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD )} {isAuthor && message.runtimeData?.sendingState && ( - + {message.runtimeData.sendingState.status === 'sending' && ( )} {message.runtimeData.sendingState.status === 'failed' && ( - error + error )} {message.runtimeData.sendingState.status === 'sent' && ( - check + check )} )} @@ -601,12 +602,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD {/* Fullscreen Image Viewer with shared-element like transition */} {fullscreenImage && createPortal(
{fullscreenImage.name} e.stopPropagation()} /> -
e.stopPropagation()}> +
e.stopPropagation()}> {isDownloadingFullscreen ? ( -
+
) : ( diff --git a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx index dfa0865..8323483 100644 --- a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx +++ b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx @@ -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({ x: 0, y: 0 }); const [contextMenuPosition, setContextMenuPosition] = useState(position); - const [animationClass, setAnimationClass] = useState('entering'); - const [reactionBarAnimationClass, setReactionBarAnimationClass] = useState('entering'); - const [reactionBarSide, setReactionBarSide] = useState<'left' | 'right'>('left'); + const [animationClass, setAnimationClass] = useState(styles.entering); + const [reactionBarAnimationClass, setReactionBarAnimationClass] = useState(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 */}
e.stopPropagation()}> {!isEmojiMenuExpanded ? ( -
+
{QUICK_REACTIONS.map((emoji, index) => ( ))}