Create new Material components, redesign the chat UI, add security settings

This commit is contained in:
2025-11-01 14:55:36 +03:00
Unverified
parent 5f49b45eed
commit fc11d06570
40 changed files with 917 additions and 618 deletions
+3
View File
@@ -69,6 +69,9 @@
"reveal": "always", "reveal": "always",
"focus": false, "focus": false,
"panel": "shared" "panel": "shared"
},
"runOptions": {
"runOn": "folderOpen"
} }
}, },
{ {
+1
View File
@@ -157,6 +157,7 @@ class DeviceSession(Base):
raw_user_agent = Column(Text, nullable=True) raw_user_agent = Column(Text, nullable=True)
# Parsed fields # Parsed fields
device_name = Column(String(128), nullable=True)
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
os_name = Column(String(64), nullable=True) os_name = Column(String(64), nullable=True)
os_version = Column(String(64), nullable=True) os_version = Column(String(64), nullable=True)
+15
View File
@@ -7,6 +7,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
from utils import create_token, get_password_hash, verify_password from utils import create_token, get_password_hash, verify_password
from validation import is_valid_password, is_valid_username, is_valid_display_name from validation import is_valid_password, is_valid_username, is_valid_display_name
@@ -51,12 +52,14 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
# Create device session and embed into JWT # Create device session and embed into JWT
raw_ua = http.headers.get("user-agent") if http else None raw_ua = http.headers.get("user-agent") if http else None
device_name = http.headers.get("x-device-name") if http else None
ua = parse_ua(raw_ua or "") ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex session_id = uuid.uuid4().hex
device = DeviceSession( device = DeviceSession(
user_id=user.id, user_id=user.id,
raw_user_agent=raw_ua, raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"), device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None), os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None), os_version=(ua.os.version_string or None),
@@ -161,11 +164,13 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
# Create initial device session # Create initial device session
raw_ua = http.headers.get("user-agent") if http else None raw_ua = http.headers.get("user-agent") if http else None
device_name = http.headers.get("x-device-name") if http else None
ua = parse_ua(raw_ua or "") ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex session_id = uuid.uuid4().hex
device = DeviceSession( device = DeviceSession(
user_id=new_user.id, user_id=new_user.id,
raw_user_agent=raw_ua, raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"), device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None), os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None), os_version=(ua.os.version_string or None),
@@ -261,9 +266,19 @@ def delete_user_as_owner(
@router.get("/logout") @router.get("/logout")
def logout( def logout(
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Revoke current session
from utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials)
if payload and payload.get("session_id"):
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id == payload["session_id"],
).update({DeviceSession.revoked: True})
current_user.online = False current_user.online = False
current_user.last_seen = datetime.now() current_user.last_seen = datetime.now()
db.commit() db.commit()
+2 -1
View File
@@ -28,7 +28,7 @@ def list_devices(
current_session_id = _get_current_session_id(credentials) current_session_id = _get_current_session_id(credentials)
sessions = ( sessions = (
db.query(DeviceSession) db.query(DeviceSession)
.filter(DeviceSession.user_id == current_user.id) .filter(DeviceSession.user_id == current_user.id, DeviceSession.revoked == False)
.order_by(DeviceSession.last_seen.desc()) .order_by(DeviceSession.last_seen.desc())
.all() .all()
) )
@@ -37,6 +37,7 @@ def list_devices(
{ {
"session_id": s.session_id, "session_id": s.session_id,
"device_type": s.device_type, "device_type": s.device_type,
"device_name": s.device_name,
"os_name": s.os_name, "os_name": s.os_name,
"os_version": s.os_version, "os_version": s.os_version,
"browser_name": s.browser_name, "browser_name": s.browser_name,
+1 -2
View File
@@ -3,7 +3,7 @@ import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { importPassword, hkdfExtractAndExpand } from "@/utils/crypto/kdf"; import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
/** /**
* Generates authentication headers for API requests * Generates authentication headers for API requests
@@ -151,7 +151,6 @@ export function getAuthToken(): string | null {
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64. * Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
*/ */
export async function deriveAuthSecret(username: string, password: string): Promise<string> { export async function deriveAuthSecret(username: string, password: string): Promise<string> {
const key = await importPassword(password);
// Use per-user salt derived from username; in future we can fetch a server-provided salt // Use per-user salt derived from username; in future we can fetch a server-provided salt
const salt = new TextEncoder().encode(`fromchat.user:${username}`); const salt = new TextEncoder().encode(`fromchat.user:${username}`);
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword // Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
+1
View File
@@ -3,6 +3,7 @@ import { getAuthHeaders } from "@/core/api/authApi";
export interface DeviceInfo { export interface DeviceInfo {
session_id: string; session_id: string;
device_name?: string;
device_type?: string; device_type?: string;
os_name?: string; os_name?: string;
os_version?: string; os_version?: string;
@@ -1,9 +0,0 @@
import type { TextField } from "mdui/components/text-field";
interface TextFieldProps extends React.ComponentPropsWithoutRef<"mdui-text-field"> {
ref?: React.Ref<TextField>
}
export function MaterialTextField({ ref, ...props }: TextFieldProps) {
return <mdui-text-field autocomplete="off" ref={ref as React.Ref<HTMLElement>} {...props} />
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import "./css/searchBar.scss"; import "./css/searchBar.scss";
import { MaterialIcon } from "@/utils/material";
interface SearchBarProps { interface SearchBarProps {
placeholder: string; placeholder: string;
@@ -57,9 +58,9 @@ export default function SearchBar({
function renderIcon(icon: string | React.ReactNode | undefined, defaultIcon?: string) { function renderIcon(icon: string | React.ReactNode | undefined, defaultIcon?: string) {
if (icon === null) return null; if (icon === null) return null;
if (!icon) { if (!icon) {
return defaultIcon ? <mdui-icon name={defaultIcon}></mdui-icon> : null; return defaultIcon ? <MaterialIcon name={defaultIcon} /> : null;
} else if (typeof icon === 'string') { } else if (typeof icon === 'string') {
return <mdui-icon name={icon}></mdui-icon>; return <MaterialIcon name={icon} />;
} else { } else {
return icon; return icon;
} }
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { checkUserSimilarity } from "@/core/api/profileApi"; import { checkUserSimilarity } from "@/core/api/profileApi";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { MaterialIcon } from "@/utils/material";
interface StatusBadgeProps { interface StatusBadgeProps {
verified: boolean; verified: boolean;
@@ -33,7 +34,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro
if (verified) { if (verified) {
return ( return (
<span className={`${className} verified`} title="Подтверждённый аккаунт"> <span className={`${className} verified`} title="Подтверждённый аккаунт">
<mdui-icon name="verified--filled" /> <MaterialIcon name="verified--filled" />
</span> </span>
); );
} }
@@ -41,7 +42,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro
if (isSimilarToVerified) { if (isSimilarToVerified) {
return ( return (
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт"> <span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
<mdui-icon name="warning" /> <MaterialIcon name="warning--filled" />
</span> </span>
); );
} }
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { verifyUser } from "@/core/api/profileApi"; import { verifyUser } from "@/core/api/profileApi";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { MaterialButton } from "@/utils/material";
interface VerifyButtonProps { interface VerifyButtonProps {
userId: number; userId: number;
@@ -34,13 +35,13 @@ export function VerifyButton({ userId, verified, onVerificationChange }: VerifyB
} }
return ( return (
<mdui-button <MaterialButton
variant="filled" variant="filled"
loading={isVerifying} loading={isVerifying}
onClick={handleVerifyToggle} onClick={handleVerifyToggle}
title={verified ? "Снять подтверждение" : "Подтвердить аккаунт"} title={verified ? "Снять подтверждение" : "Подтвердить аккаунт"}
> >
{verified ? "Отменить подтверждение" : "Подтвердить"} {verified ? "Отменить подтверждение" : "Подтвердить"}
</mdui-button> </MaterialButton>
); );
} }
+7
View File
@@ -631,3 +631,10 @@ export interface StopDmTypingRequest extends WebSocketMessage {
recipientId: number; recipientId: number;
}; };
} }
// -------------
// Utility types
// -------------
export type Override<TBase, TExt> = Omit<TBase, keyof TExt> & TExt;
+3 -2
View File
@@ -7,12 +7,12 @@ import { API_BASE_URL } from "@/core/config";
import { useRef } from "react"; import { useRef } from "react";
import type { TextField } from "mdui/components/text-field"; import type { TextField } from "mdui/components/text-field";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/MaterialTextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; import { isElectron } from "@/core/electron/electron";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./auth.scss"; import "./auth.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialTextField } from "@/utils/material";
export default function LoginPage() { export default function LoginPage() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]); const [alerts, updateAlerts] = useImmer<Alert[]>([]);
@@ -114,6 +114,7 @@ export default function LoginPage() {
showAlert("danger", "Ошибка соединения с сервером"); showAlert("danger", "Ошибка соединения с сервером");
} }
}}> }}>
<MaterialTextField <MaterialTextField
label="@Имя пользователя" label="@Имя пользователя"
id="login-username" id="login-username"
@@ -136,7 +137,7 @@ export default function LoginPage() {
required required
ref={passwordElement} /> ref={passwordElement} />
<mdui-button type="submit">Войти</mdui-button> <MaterialButton type="submit">Войти</MaterialButton>
</form> </form>
<div className="text-center"> <div className="text-center">
+2 -2
View File
@@ -6,7 +6,7 @@ import { TextField } from "mdui/components/text-field";
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types"; import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/MaterialTextField"; import { MaterialButton, MaterialTextField } from "@/utils/material";
import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./auth.scss"; import "./auth.scss";
@@ -156,7 +156,7 @@ export default function RegisterPage() {
required required
ref={confirmPasswordElement} /> ref={confirmPasswordElement} />
<mdui-button type="submit">Зарегистрироваться</mdui-button> <MaterialButton type="submit">Зарегистрироваться</MaterialButton>
</form> </form>
<div className="text-center"> <div className="text-center">
@@ -0,0 +1,36 @@
.change-password-dialog .cpd-container {
padding: 16px;
.cpd-titlebar {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 16px;
gap: 10px;
.cpd-title {
font-size: 20px;
}
}
.cpd-content form {
display: flex;
flex-direction: column;
gap: 16px;
.cpd-logout-all {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.cpd-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
@use "../../../css/colors" as *; @use "../../../css/colors" as *;
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
@use "right-panel" as *;
.chat-input-wrapper { .chat-input-wrapper {
position: relative; position: relative;
margin: 0 10px 10px 10px; margin: 0 10px - $scrollbar-width 10px 10px;
position: sticky; position: sticky;
bottom: 10px; bottom: 10px;
z-index: 1; z-index: 1;
-9
View File
@@ -27,15 +27,6 @@
width: 100%; width: 100%;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
.chat-main {
flex-grow: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
overflow-y: auto;
}
} }
} }
+44 -5
View File
@@ -2,18 +2,56 @@
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
$scrollbar-width: 8px;
.chat-wrapper {
flex-grow: 1;
height: 100%;
position: relative;
overflow: hidden;
.chat-main { .chat-main {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
overflow-y: auto;
// Fancy floating thin scrollbar with invisible transparent margin
scrollbar-width: thin;
scrollbar-color: rgba($color-dark-primary, 0.3) transparent;
&::-webkit-scrollbar {
width: $scrollbar-width;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba($color-dark-primary, 0.3);
border-radius: 4px;
margin: 2px;
&:hover {
background: rgba($color-dark-primary, 0.5);
}
}
.chat-header { .chat-header {
padding: 16px; padding: 16px;
background: rgba($color-dark-surface-container, 0.8); margin: 10px 10px - $scrollbar-width 0 10px;
background: rgba($color-dark-surface-container, 0.7);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border-radius: 30px;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
display: flex; display: flex;
align-items: center; align-items: center;
box-shadow: 0 4px 20px rgba($color-dark-primary, 0.1); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 5;
position: sticky; position: sticky;
top: 0; top: 10px;
z-index: 5;
.chat-header-avatar { .chat-header-avatar {
width: 45px; width: 45px;
@@ -124,3 +162,4 @@
} }
} }
} }
}
+1
View File
@@ -12,3 +12,4 @@
@use "profile-dialog"; @use "profile-dialog";
@use "typing-indicators"; @use "typing-indicators";
@use "suspension-dialog"; @use "suspension-dialog";
@use "changePasswordDialog";
+12 -11
View File
@@ -12,6 +12,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineStatus } from "./right/OnlineStatus"; import { OnlineStatus } from "./right/OnlineStatus";
import { Input } from "@/core/components/Input"; import { Input } from "@/core/components/Input";
import { StyledDialog } from "@/core/components/StyledDialog"; import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialButton, MaterialFab, MaterialIcon } from "@/utils/material";
interface SectionProps { interface SectionProps {
type: string; type: string;
@@ -55,7 +56,7 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
return ( return (
<div className={`section ${type} ${error ? 'error' : ''}`}> <div className={`section ${type} ${error ? 'error' : ''}`}>
<mdui-icon name={icon} /> <MaterialIcon name={icon} />
<div className="content-container"> <div className="content-container">
<label className="label">{label}</label> <label className="label">{label}</label>
{valueComponent} {valueComponent}
@@ -442,12 +443,11 @@ export function ProfileDialog() {
className="profile-dialog" className="profile-dialog"
afterChildren={ afterChildren={
currentData.isOwnProfile && ( currentData.isOwnProfile && (
<mdui-fab <MaterialFab
icon="check" icon="check"
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`} className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving} />
/>
) )
} }
> >
@@ -457,16 +457,16 @@ export function ProfileDialog() {
src={currentData.profilePicture || defaultAvatar} src={currentData.profilePicture || defaultAvatar}
alt="Profile Picture" alt="Profile Picture"
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; e.target.src = defaultAvatar;
target.src = defaultAvatar;
}} }}
/> />
{currentData.isOwnProfile && ( {currentData.isOwnProfile && (
<div <div
className="profile-picture-edit-overlay" className="profile-picture-edit-overlay"
onClick={handleProfilePictureClick} onClick={handleProfilePictureClick}
> >
<mdui-icon name="camera_alt--filled" /> <MaterialIcon name="camera_alt--filled" />
</div> </div>
)} )}
</div> </div>
@@ -481,6 +481,7 @@ export function ProfileDialog() {
onChange={handleDisplayNameChange} onChange={handleDisplayNameChange}
readOnly={!currentData.isOwnProfile} readOnly={!currentData.isOwnProfile}
placeholder="Имя" /> placeholder="Имя" />
<StatusBadge <StatusBadge
verified={currentData.verified || false} verified={currentData.verified || false}
userId={currentData.userId} userId={currentData.userId}
@@ -502,22 +503,22 @@ export function ProfileDialog() {
<div className="admin-actions-section"> <div className="admin-actions-section">
<h3 className="admin-actions-header">Admin Actions</h3> <h3 className="admin-actions-header">Admin Actions</h3>
<div className="admin-buttons"> <div className="admin-buttons">
<mdui-button <MaterialButton
variant="filled" variant="filled"
color="error" color="error"
icon={currentData.suspended ? "check_circle--filled" : "block--filled"} icon={currentData.suspended ? "check_circle--filled" : "block--filled"}
onClick={handleSuspend} onClick={handleSuspend}
> >
{currentData.suspended ? "Unsuspend Account" : "Suspend Account"} {currentData.suspended ? "Unsuspend Account" : "Suspend Account"}
</mdui-button> </MaterialButton>
<mdui-button <MaterialButton
variant="filled" variant="filled"
color="error" color="error"
icon="delete_forever--filled" icon="delete_forever--filled"
onClick={handleDelete} onClick={handleDelete}
> >
Delete Account Delete Account
</mdui-button> </MaterialButton>
<VerifyButton <VerifyButton
userId={currentData.userId!} userId={currentData.userId!}
verified={currentData.verified || false} verified={currentData.verified || false}
@@ -1,4 +1,5 @@
import { StyledDialog } from "@/core/components/StyledDialog"; import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialIcon } from "@/utils/material";
interface SuspensionDialogProps { interface SuspensionDialogProps {
reason: string; reason: string;
@@ -13,7 +14,7 @@ export function SuspensionDialog({ reason, open, onOpenChange }: SuspensionDialo
onOpenChange={onOpenChange}> onOpenChange={onOpenChange}>
<div className="suspension-dialog-content"> <div className="suspension-dialog-content">
<div className="suspension-icon-section"> <div className="suspension-icon-section">
<mdui-icon name="block--filled" className="suspension-icon" /> <MaterialIcon name="block--filled" className="suspension-icon" />
</div> </div>
<div className="suspension-text"> <div className="suspension-text">
+14 -14
View File
@@ -1,32 +1,32 @@
import { useAppState, type ChatTabs } from "@/pages/chat/state"; import { useAppState, type ChatTabs } from "@/pages/chat/state";
import { UnifiedChatsList } from "./UnifiedChatsList"; import { UnifiedChatsList } from "./UnifiedChatsList";
import type { Tabs } from "mdui/components/tabs"; import { MaterialTab, MaterialTabPanel, MaterialTabs } from "@/utils/material";
export function ChatTabs() { export function ChatTabs() {
const { chat, setActiveTab } = useAppState(); const { chat, setActiveTab } = useAppState();
return ( return (
<div className="chat-tabs"> <div className="chat-tabs">
<mdui-tabs <MaterialTabs
value={chat.activeTab} value={chat.activeTab}
full-width full-width
onChange={(e) => setActiveTab((e.target as Tabs).value as ChatTabs)}> onChange={(e) => setActiveTab(e.target.value as ChatTabs)}>
<mdui-tab value="chats"> <MaterialTab value="chats">
Чаты Чаты
</mdui-tab> </MaterialTab>
<mdui-tab value="channels"> <MaterialTab value="channels">
Каналы Каналы
</mdui-tab> </MaterialTab>
<mdui-tab value="contacts"> <MaterialTab value="contacts">
Контакты Контакты
</mdui-tab> </MaterialTab>
<mdui-tab-panel slot="panel" value="chats"> <MaterialTabPanel slot="panel" value="chats">
<UnifiedChatsList /> <UnifiedChatsList />
</mdui-tab-panel> </MaterialTabPanel>
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel> <MaterialTabPanel slot="panel" value="channels">Скоро будет...</MaterialTabPanel>
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel> <MaterialTabPanel slot="panel" value="contacts">Скоро будет...</MaterialTabPanel>
</mdui-tabs> </MaterialTabs>
</div> </div>
); );
} }
+9 -13
View File
@@ -4,29 +4,25 @@ import { SettingsDialog } from "./settings/SettingsDialog";
import { UsernameSearch } from "./UsernameSearch"; import { UsernameSearch } from "./UsernameSearch";
import { ChatTabs } from "./ChatTabs"; import { ChatTabs } from "./ChatTabs";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import { MaterialBottomAppBar, MaterialFab, MaterialIconButton } from "@/utils/material";
function BottomAppBar() { function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false); const [settingsOpen, onSettingsOpenChange] = useState(false);
const { logout } = useAppState(); const { logout } = useAppState();
const handleLogout = () => {
logout();
};
return ( return (
<> <>
<mdui-bottom-app-bar> <MaterialBottomAppBar>
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon> <MaterialIconButton icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
<mdui-button-icon icon="group_add--filled"></mdui-button-icon> <MaterialIconButton icon="group_add--filled" />
<div style={{ flexGrow: 1 }}></div> <div style={{ flexGrow: 1 }}></div>
<mdui-button-icon <MaterialIconButton
icon="logout--filled" icon="logout--filled"
id="logout-btn" id="logout-btn"
onClick={handleLogout} onClick={logout}
title="Выйти" title="Выйти" />
></mdui-button-icon> <MaterialFab icon="edit--filled" />
<mdui-fab icon="edit--filled"></mdui-fab> </MaterialBottomAppBar>
</mdui-bottom-app-bar>
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} /> <SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
</> </>
); );
@@ -10,6 +10,7 @@ import { websocket } from "@/core/websocket";
import { onlineStatusManager } from "@/core/onlineStatusManager"; import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator"; import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { MaterialBadge, MaterialCircularProgress, MaterialList, MaterialListItem } from "@/utils/material";
interface PublicChat { interface PublicChat {
id: string; id: string;
@@ -223,17 +224,15 @@ export function UnifiedChatsList() {
} }
if (isLoadingUsers) { if (isLoadingUsers) {
return ( return <MaterialCircularProgress />;
<mdui-circular-progress />
);
} }
return ( return (
<mdui-list> <MaterialList>
{allChats.map((chat) => { {allChats.map((chat) => {
if (chat.type === "public") { if (chat.type === "public") {
return ( return (
<mdui-list-item <MaterialListItem
key={`public-${chat.id}`} key={`public-${chat.id}`}
headline={chat.name} headline={chat.name}
onClick={() => handlePublicChatClick(chat.name)} onClick={() => handlePublicChatClick(chat.name)}
@@ -255,11 +254,11 @@ export function UnifiedChatsList() {
objectFit: "cover" objectFit: "cover"
}} }}
/> />
</mdui-list-item> </MaterialListItem>
); );
} else { } else {
return ( return (
<mdui-list-item <MaterialListItem
key={`dm-${chat.id}`} key={`dm-${chat.id}`}
headline={chat.display_name} headline={chat.display_name}
onClick={() => handleDMClick(chat)} onClick={() => handleDMClick(chat)}
@@ -288,20 +287,20 @@ export function UnifiedChatsList() {
display: "block" display: "block"
}} }}
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = defaultAvatar; e.target.src = defaultAvatar;
}} }}
/> />
<OnlineIndicator userId={chat.id} /> <OnlineIndicator userId={chat.id} />
</div> </div>
{chat.unreadCount > 0 && ( {chat.unreadCount > 0 && (
<mdui-badge slot="end-icon"> <MaterialBadge slot="end-icon">
{chat.unreadCount} {chat.unreadCount}
</mdui-badge> </MaterialBadge>
)} )}
</mdui-list-item> </MaterialListItem>
); );
} }
})} })}
</mdui-list> </MaterialList>
); );
} }
@@ -7,6 +7,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator"; import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import SearchBar from "@/core/components/SearchBar"; import SearchBar from "@/core/components/SearchBar";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
interface SearchUser extends User { interface SearchUser extends User {
publicKey?: string | null; publicKey?: string | null;
@@ -121,7 +122,7 @@ export function UsernameSearch() {
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpanded={handleToggleExpanded} onToggleExpanded={handleToggleExpanded}
leftIcon={isExpanded ? ( leftIcon={isExpanded ? (
<mdui-button-icon <MaterialIconButton
className="back-button" className="back-button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -134,7 +135,7 @@ export function UsernameSearch() {
> >
{isSearching && ( {isSearching && (
<div className="search-loading"> <div className="search-loading">
<mdui-circular-progress value={0}></mdui-circular-progress> <MaterialCircularProgress />
<span>Поиск...</span> <span>Поиск...</span>
</div> </div>
)} )}
@@ -146,9 +147,9 @@ export function UsernameSearch() {
)} )}
{!isSearching && searchResults.length > 0 && ( {!isSearching && searchResults.length > 0 && (
<mdui-list> <MaterialList>
{searchResults.map((searchUser) => ( {searchResults.map((searchUser) => (
<mdui-list-item <MaterialListItem
key={searchUser.id} key={searchUser.id}
headline={searchUser.username} headline={searchUser.username}
onClick={() => handleUserClick(searchUser)} onClick={() => handleUserClick(searchUser)}
@@ -174,14 +175,14 @@ export function UsernameSearch() {
display: "block" display: "block"
}} }}
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = defaultAvatar; e.target.src = defaultAvatar;
}} }}
/> />
<OnlineIndicator userId={searchUser.id} /> <OnlineIndicator userId={searchUser.id} />
</div> </div>
</mdui-list-item> </MaterialListItem>
))} ))}
</mdui-list> </MaterialList>
)} )}
{!isSearching && searchQuery.length < 2 && ( {!isSearching && searchQuery.length < 2 && (
@@ -1,3 +1,4 @@
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import "./css/cropper-dialog.scss"; import "./css/cropper-dialog.scss";
export function CropperDialog() { export function CropperDialog() {
@@ -6,14 +7,14 @@ export function CropperDialog() {
<div className="cropper-dialog-content"> <div className="cropper-dialog-content">
<div className="cropper-header"> <div className="cropper-header">
<h3>Обрезать фото профиля</h3> <h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon> <MaterialIconButton icon="close" id="cropper-close" />
</div> </div>
<div className="cropper-container"> <div className="cropper-container">
<div id="cropper-area"></div> <div id="cropper-area"></div>
</div> </div>
<div className="cropper-actions"> <div className="cropper-actions">
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button> <MaterialButton id="crop-cancel" variant="outlined">Отмена</MaterialButton>
<mdui-button id="crop-save">Сохранить</mdui-button> <MaterialButton id="crop-save">Сохранить</MaterialButton>
</div> </div>
</div> </div>
</mdui-dialog> </mdui-dialog>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "@/core/types"; import type { Size2D, Rect } from "@/core/types";
import { MaterialButton } from "@/utils/material";
interface ImageCropperProps { interface ImageCropperProps {
onCrop: (croppedImageData: string) => void; onCrop: (croppedImageData: string) => void;
@@ -180,12 +181,12 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
alt="Crop source" alt="Crop source"
/> />
<div className="cropper-actions"> <div className="cropper-actions">
<mdui-button onClick={handleCrop} disabled={!isLoaded}> <MaterialButton onClick={handleCrop} disabled={!isLoaded}>
Обрезать Обрезать
</mdui-button> </MaterialButton>
<mdui-button variant="outlined" onClick={onCancel}> <MaterialButton variant="outlined" onClick={onCancel}>
Отмена Отмена
</mdui-button> </MaterialButton>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,82 @@
import { useState } from "react";
import { StyledDialog } from "@/core/components/StyledDialog";
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";
export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) {
const { user } = useAppState();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [logoutAll, setLogoutAll] = useState(true);
const [busy, setBusy] = useState(false);
return (
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="change-password-dialog">
<div className="cpd-container">
<div className="cpd-titlebar">
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className="cpd-title">Изменить пароль</div>
</div>
<div className="cpd-content">
<form onSubmit={async (e) => {
e.preventDefault();
if (!user.authToken || !user.currentUser?.username) return;
if (!current || !next || next !== confirm) return;
setBusy(true);
try {
await changePassword(user.authToken, user.currentUser?.username, current, next, logoutAll);
setCurrent("");
setNext("");
setConfirm("");
onOpenChange(false);
} finally {
setBusy(false);
}
}}>
<MaterialTextField
name="cpd-current-password"
label="Текущий пароль"
type="password"
value={current}
onInput={(e) => setCurrent(e.target.value)}
variant="outlined"
toggle-password
required />
<MaterialTextField
name="cpd-new-password"
label="Новый пароль"
type="password"
value={next}
onInput={(e) => setNext(e.target.value)}
variant="outlined"
toggle-password
required />
<MaterialTextField
name="cpd-confirm-password"
label="Подтвердите пароль"
type="password"
value={confirm}
onInput={(e) => setConfirm(e.target.value)}
variant="outlined"
toggle-password
required />
<div className="cpd-logout-all">
<MaterialSwitch
name="cpd-logout-all"
checked={logoutAll}
onInput={(e) => setLogoutAll(e.target.checked)} />
<label htmlFor="cpd-logout-all">Выйти на всех устройствах (кроме текущего)</label>
</div>
<div className="cpd-actions">
<MaterialButton type="submit" disabled={busy}>Сохранить</MaterialButton>
</div>
</form>
</div>
</div>
</StyledDialog>
);
}
@@ -5,11 +5,11 @@ import { StyledDialog } from "@/core/components/StyledDialog";
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "@/core/push-notifications/push-notifications"; import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; import { isElectron } from "@/core/electron/electron";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "@/core/api/authApi"; import { getAuthHeaders } from "@/core/api/authApi";
import { changePassword } from "@/core/api/securityApi"; import ChangePasswordDialog from "./ChangePasswordDialog";
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi"; import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { MaterialButton, MaterialIconButton, MaterialList, MaterialListItem, MaterialSwitch } from "@/utils/material";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings"); const [activePanel, setActivePanel] = useState("notifications-settings");
@@ -18,10 +18,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const user = useAppState(state => state.user); const user = useAppState(state => state.user);
const logout = useAppState(state => state.logout); const logout = useAppState(state => state.logout);
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]); const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
const [cpCurrent, setCpCurrent] = useState(""); const [cpOpen, setCpOpen] = useState(false);
const [cpNext, setCpNext] = useState("");
const [cpConfirm, setCpConfirm] = useState("");
const [cpLogoutAll, setCpLogoutAll] = useState(true);
useEffect(() => { useEffect(() => {
setPushSupported(isSupported()); setPushSupported(isSupported());
@@ -79,15 +76,16 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
}; };
return ( return (
<>
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="settings-dialog"> <StyledDialog open={isOpen} onOpenChange={onOpenChange} className="settings-dialog">
<div id="settings-dialog-inner"> <div id="settings-dialog-inner">
<div className="header"> <div className="header">
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)}></mdui-button-icon> <MaterialIconButton icon="close" id="settings-close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title> <div className="title">Настройки</div>
</div> </div>
<div id="settings-menu"> <div id="settings-menu">
<mdui-list> <MaterialList>
<mdui-list-item <MaterialListItem
icon="notifications--filled" icon="notifications--filled"
rounded rounded
active={activePanel === "notifications-settings"} active={activePanel === "notifications-settings"}
@@ -95,8 +93,8 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
Уведомления Уведомления
</mdui-list-item> </MaterialListItem>
<mdui-list-item <MaterialListItem
icon="security--filled" icon="security--filled"
rounded rounded
active={activePanel === "security-settings"} active={activePanel === "security-settings"}
@@ -104,8 +102,8 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
Безопасность Безопасность
</mdui-list-item> </MaterialListItem>
<mdui-list-item <MaterialListItem
icon="devices--filled" icon="devices--filled"
rounded rounded
active={activePanel === "devices-settings"} active={activePanel === "devices-settings"}
@@ -113,8 +111,8 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
Устройства Устройства
</mdui-list-item> </MaterialListItem>
<mdui-list-item <MaterialListItem
icon="info--filled" icon="info--filled"
rounded rounded
active={activePanel === "about-settings"} active={activePanel === "about-settings"}
@@ -122,78 +120,50 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
О приложении О приложении
</mdui-list-item> </MaterialListItem>
</mdui-list> </MaterialList>
<div className="screen"> <div className="screen">
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}> <div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3> <h3>Уведомления</h3>
{pushSupported && ( {pushSupported && (
<mdui-switch <MaterialSwitch
checked={pushNotificationsEnabled} checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)} onInput={(e) => handlePushNotificationToggle(e.target.checked)}>
>
Push уведомления Push уведомления
</mdui-switch> </MaterialSwitch>
)} )}
</div> </div>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}> <div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
<h3>Безопасность</h3> <h3>Безопасность</h3>
<form onSubmit={async (e) => { <MaterialButton variant="tonal" onClick={() => setCpOpen(true)}>Изменить пароль</MaterialButton>
e.preventDefault();
if (!user.authToken || !user.username) return;
if (!cpCurrent || !cpNext || cpNext !== cpConfirm) return;
try {
await changePassword(user.authToken, user.username, cpCurrent, cpNext, cpLogoutAll);
setCpCurrent("");
setCpNext("");
setCpConfirm("");
} catch (err) {
console.error(err);
}
}}>
<mdui-text-field label="Текущий пароль" type="password" value={cpCurrent} onInput={(e: any) => setCpCurrent(e.target.value)} variant="outlined" toggle-password></mdui-text-field>
<mdui-text-field label="Новый пароль" type="password" value={cpNext} onInput={(e: any) => setCpNext(e.target.value)} variant="outlined" toggle-password></mdui-text-field>
<mdui-text-field label="Подтвердите пароль" type="password" value={cpConfirm} onInput={(e: any) => setCpConfirm(e.target.value)} variant="outlined" toggle-password></mdui-text-field>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<mdui-switch checked={cpLogoutAll} onInput={(e: any) => setCpLogoutAll(e.target.checked)}>Выйти на всех устройствах (кроме текущего)</mdui-switch>
<div style={{ flexGrow: 1 }}></div>
<mdui-button type="submit" variant="tonal">Сохранить</mdui-button>
</div>
</form>
</div>
<div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}>
<h3>Язык</h3>
<mdui-select label="Выберите язык" variant="outlined">
<mdui-menu-item value="ru">Русский</mdui-menu-item>
<mdui-menu-item value="en">English</mdui-menu-item>
<mdui-menu-item value="es">Español</mdui-menu-item>
</mdui-select>
</div>
<div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
<h3>Хранилище</h3>
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
<mdui-linear-progress value={25}></mdui-linear-progress>
<mdui-button variant="outlined">Очистить кэш</mdui-button>
</div> </div>
<div id="devices-settings" className={`settings-panel ${activePanel === "devices-settings" ? "active" : ""}`}> <div id="devices-settings" className={`settings-panel ${activePanel === "devices-settings" ? "active" : ""}`}>
<h3>Устройства</h3> <h3>Устройства</h3>
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}> <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<mdui-button variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</mdui-button> <MaterialButton variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</MaterialButton>
<mdui-button variant="outlined" onClick={async () => { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве</mdui-button> <MaterialButton variant="outlined" onClick={async () => { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве</MaterialButton>
</div> </div>
<mdui-list> <MaterialList>
{devices.map((d) => ( {devices.map((d) => (
<mdui-list-item key={d.session_id} icon={d.current ? "devices_other--filled" : "devices--filled"} rounded end-icon={!d.current ? "logout--filled" : undefined} onEndIconClick={async () => { if (!user.authToken || d.current) return; await revokeDevice(user.authToken, d.session_id); const list = await listDevices(user.authToken); updateDevices(() => list); }}> <MaterialListItem
<div slot="headline">{d.browser_name || "Браузер"} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}</div> key={d.session_id}
icon={d.current ? "devices_other--filled" : "devices--filled"}
rounded
end-icon={!d.current ? "logout--filled" : undefined}
onClick={async () => {
if (!user.authToken || d.current) return;
await revokeDevice(user.authToken, d.session_id);
const list = await listDevices(user.authToken);
updateDevices(() => list);
}}
>
<div slot="headline">{d.device_name || (d.browser_name || "Браузер")} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}</div>
<div slot="description">Последняя активность: {d.last_seen || "—"}</div> <div slot="description">Последняя активность: {d.last_seen || "—"}</div>
</mdui-list-item> </MaterialListItem>
))} ))}
</mdui-list> </MaterialList>
</div> </div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}> <div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
@@ -205,5 +175,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
</div> </div>
</div> </div>
</StyledDialog> </StyledDialog>
<ChangePasswordDialog isOpen={cpOpen} onOpenChange={setCpOpen} />
</>
); );
} }
@@ -6,6 +6,7 @@ import type { Message } from "@/core/types";
import Quote from "@/core/components/Quote"; import Quote from "@/core/components/Quote";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu"; import { EmojiMenu } from "./EmojiMenu";
import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material";
interface ChatInputWrapperProps { interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void; onSendMessage: (message: string, files: File[]) => void;
@@ -155,12 +156,12 @@ export function ChatInputWrapper(
style={{ overflow: "hidden" }} style={{ overflow: "hidden" }}
> >
<div className="reply-preview contextual-preview"> <div className="reply-preview contextual-preview">
<mdui-icon name="edit" /> <MaterialIcon name="edit" />
<Quote className="reply-content contextual-content" background="surfaceContainer"> <Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{editingMessage!.username}</span> <span className="reply-username">{editingMessage!.username}</span>
<span className="reply-text">{editingMessage!.content}</span> <span className="reply-text">{editingMessage!.content}</span>
</Quote> </Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon> <MaterialIconButton icon="close" className="reply-cancel" onClick={onClearEdit}></MaterialIconButton>
</div> </div>
</motion.div> </motion.div>
)} )}
@@ -175,12 +176,12 @@ export function ChatInputWrapper(
style={{ overflow: "hidden" }} style={{ overflow: "hidden" }}
> >
<div className="reply-preview contextual-preview"> <div className="reply-preview contextual-preview">
<mdui-icon name="reply" /> <MaterialIcon name="reply" />
<Quote className="reply-content contextual-content" background="surfaceContainer"> <Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{replyTo!.username}</span> <span className="reply-username">{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span> <span className="reply-text">{replyTo!.content}</span>
</Quote> </Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon> <MaterialIconButton icon="close" className="reply-cancel" onClick={onClearReply}></MaterialIconButton>
</div> </div>
</motion.div> </motion.div>
)} )}
@@ -195,7 +196,7 @@ export function ChatInputWrapper(
style={{ overflow: "hidden" }} style={{ overflow: "hidden" }}
> >
<div className="attachments-preview contextual-preview"> <div className="attachments-preview contextual-preview">
<mdui-icon name="attach_file" /> <MaterialIcon name="attach_file" />
<div className="attachments-chips"> <div className="attachments-chips">
{selectedFiles.map((file, i) => ( {selectedFiles.map((file, i) => (
<mdui-chip <mdui-chip
@@ -211,19 +212,19 @@ export function ChatInputWrapper(
} }
}} }}
> >
<mdui-icon slot="icon" name="attach_file"></mdui-icon> <MaterialIcon slot="icon" name="attach_file"></MaterialIcon>
<span className="name">{file.name}</span> <span className="name">{file.name}</span>
</mdui-chip> </mdui-chip>
))} ))}
</div> </div>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon> <MaterialIconButton icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></MaterialIconButton>
</div> </div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<div className="chat-input"> <div className="chat-input">
<div className="left-buttons"> <div className="left-buttons">
<mdui-button-icon <MaterialIconButton
icon="mood" icon="mood"
onClick={handleEmojiButtonClick} onClick={handleEmojiButtonClick}
onMouseDown={e => e.stopPropagation()} onMouseDown={e => e.stopPropagation()}
@@ -240,7 +241,7 @@ export function ChatInputWrapper(
onTextChange={handleMessageChange} onTextChange={handleMessageChange}
onEnter={handleSubmit} /> onEnter={handleSubmit} />
<div className="buttons"> <div className="buttons">
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon> <MaterialIconButton icon="attach_file" onClick={handleAttachClick} className="attach-btn"></MaterialIconButton>
<button type="submit" className="send-btn"> <button type="submit" className="send-btn">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span> <span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button> </button>
@@ -250,7 +251,7 @@ export function ChatInputWrapper(
<MaterialDialog open={errorOpen} onOpenChange={setErrorOpen} close-on-overlay-click close-on-esc> <MaterialDialog open={errorOpen} onOpenChange={setErrorOpen} close-on-overlay-click close-on-esc>
<div slot="headline">Ошибка</div> <div slot="headline">Ошибка</div>
<div>Общий размер вложений превышает 4 ГБ.</div> <div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button> <MaterialButton slot="action" onClick={() => setErrorOpen(false)}>Закрыть</MaterialButton>
</MaterialDialog> </MaterialDialog>
<EmojiMenu <EmojiMenu
@@ -6,6 +6,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { MaterialDialog } from "@/core/components/Dialog"; import { MaterialDialog } from "@/core/components/Dialog";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types"; import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
import { MaterialButton } from "@/utils/material";
interface ChatMessagesProps { interface ChatMessagesProps {
messages?: MessageType[]; messages?: MessageType[];
@@ -147,8 +148,8 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
headline="Удалить сообщение?" headline="Удалить сообщение?"
open={deleteDialogOpen} open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}> onOpenChange={setDeleteDialogOpen}>
<mdui-button slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</mdui-button> <MaterialButton slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</MaterialButton>
<mdui-button slot="action" variant="filled" onClick={confirmDelete}>Удалить</mdui-button> <MaterialButton slot="action" variant="filled" onClick={confirmDelete}>Удалить</MaterialButton>
</MaterialDialog> </MaterialDialog>
{/* Context Menu */} {/* Context Menu */}
+16 -17
View File
@@ -16,6 +16,7 @@ import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { parseProfileLink } from "@/core/profileLinks"; import { parseProfileLink } from "@/core/profileLinks";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
interface MessageReactionsProps { interface MessageReactionsProps {
reactions?: Reaction[]; reactions?: Reaction[];
@@ -415,10 +416,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
} }
async function handleLinkClick(e: React.MouseEvent<HTMLDivElement>) { async function handleLinkClick(e: React.MouseEvent<HTMLDivElement>) {
const target = e.target as HTMLElement; if (e.target.tagName === 'A') {
const link = (e.target as unknown as HTMLAnchorElement).href;
if (target.tagName === 'A') { const profileLink = parseProfileLink(link);
const profileLink = parseProfileLink((target as HTMLAnchorElement).href);
if (profileLink) { if (profileLink) {
e.preventDefault(); e.preventDefault();
@@ -443,7 +443,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
isOwnProfile: userProfile.id === user.currentUser?.id isOwnProfile: userProfile.id === user.currentUser?.id
}); });
} else { } else {
throw new Error("Invalid link: " + (target as HTMLAnchorElement).href); throw new Error(`Invalid link: ${link}`);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch user profile from link:", error); console.error("Failed to fetch user profile from link:", error);
@@ -483,8 +483,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)} src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
alt={message.username} alt={message.username}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; e.target.src = defaultAvatar;
target.src = defaultAvatar;
}} }}
/> />
</div> </div>
@@ -517,7 +516,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
onClick={handleLinkClick} /> onClick={handleLinkClick} />
{message.files && message.files.length > 0 && ( {message.files && message.files.length > 0 && (
<mdui-list className="message-attachments"> <MaterialList className="message-attachments">
{message.files.map((file, idx) => { {message.files.map((file, idx) => {
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
const isEncryptedDm = Boolean(isDm && file.encrypted); const isEncryptedDm = Boolean(isDm && file.encrypted);
@@ -542,7 +541,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
/> />
{(!loadedImages.has(file.path) || isSending) && ( {(!loadedImages.has(file.path) || isSending) && (
<div className="loading-overlay"> <div className="loading-overlay">
<mdui-circular-progress /> <MaterialCircularProgress />
</div> </div>
)} )}
</div> </div>
@@ -554,18 +553,18 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
await downloadFile(file); await downloadFile(file);
}} }}
> >
<mdui-list-item> <MaterialListItem>
<span className="with-icon-gap"> <span className="with-icon-gap">
{isDownloading ? <mdui-circular-progress /> : null} {isDownloading ? <MaterialCircularProgress /> : null}
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
</span> </span>
</mdui-list-item> </MaterialListItem>
</a> </a>
)} )}
</div> </div>
); );
})} })}
</mdui-list> </MaterialList>
)} )}
<Reactions <Reactions
@@ -585,7 +584,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
{isAuthor && message.runtimeData?.sendingState && ( {isAuthor && message.runtimeData?.sendingState && (
<span className="message-status-indicator"> <span className="message-status-indicator">
{message.runtimeData.sendingState.status === 'sending' && ( {message.runtimeData.sendingState.status === 'sending' && (
<mdui-circular-progress style={{ width: '16px', height: '16px' }} /> <MaterialCircularProgress style={{ width: '16px', height: '16px' }} />
)} )}
{message.runtimeData.sendingState.status === 'failed' && ( {message.runtimeData.sendingState.status === 'failed' && (
<span className="material-symbols error-icon">error</span> <span className="material-symbols error-icon">error</span>
@@ -617,13 +616,13 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
/> />
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}> <div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
<mdui-button-icon icon="close" onClick={closeFullscreen} /> <MaterialIconButton icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? ( {isDownloadingFullscreen ? (
<div className="progress-wrapper"> <div className="progress-wrapper">
<mdui-circular-progress /> <MaterialCircularProgress />
</div> </div>
) : ( ) : (
<mdui-button-icon icon="download" onClick={downloadImage} /> <MaterialIconButton icon="download" onClick={downloadImage} />
)} )}
</div> </div>
</div>, </div>,
@@ -14,6 +14,7 @@ import { TypingIndicator } from "./TypingIndicator";
import { OnlineStatus } from "./OnlineStatus"; import { OnlineStatus } from "./OnlineStatus";
import { typingManager } from "@/core/typingManager"; import { typingManager } from "@/core/typingManager";
import { PublicChatPanel } from "./panels/PublicChatPanel"; import { PublicChatPanel } from "./panels/PublicChatPanel";
import { MaterialIcon, MaterialIconButton } from "@/utils/material";
interface MessagePanelRendererProps { interface MessagePanelRendererProps {
panel: MessagePanel | null; panel: MessagePanel | null;
@@ -199,13 +200,16 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={panelKey} key={panelKey}
ref={messagePanelRef}
className="chat-main"
id="chat-inner"
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="chat-wrapper"
>
<div
ref={messagePanelRef}
className="chat-main"
id="chat-inner"
onDragEnter={panel ? (e) => { onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return; if (!e.dataTransfer) return;
e.preventDefault(); e.preventDefault();
@@ -252,7 +256,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
<ChatHeaderText panel={panel} /> <ChatHeaderText panel={panel} />
</div> </div>
{panel?.isDm() && ( {panel?.isDm() && (
<mdui-button-icon onClick={handleCallClick} icon="call--filled" /> <MaterialIconButton onClick={handleCallClick} icon="call--filled" />
)} )}
</div> </div>
</div> </div>
@@ -324,7 +328,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
> >
<div className="file-overlay-wrapper"> <div className="file-overlay-wrapper">
<div className="file-overlay-inner"> <div className="file-overlay-inner">
<mdui-icon name="upload_file" /> <MaterialIcon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span> <span>Отпустите файл(ы) для добавления</span>
</div> </div>
</div> </div>
@@ -391,6 +395,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
/> />
</> </>
)} )}
</div>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
@@ -4,6 +4,7 @@ import useCall from "@/pages/chat/hooks/useCall";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { id } from "@/utils/utils"; import { id } from "@/utils/utils";
import { MaterialIconButton } from "@/utils/material";
export function CallWindow() { export function CallWindow() {
const { chat, toggleCallMinimize, user } = useAppState(); const { chat, toggleCallMinimize, user } = useAppState();
@@ -196,8 +197,8 @@ export function CallWindow() {
onMouseDown={(e) => { onMouseDown={(e) => {
if (call.isMinimized) { if (call.isMinimized) {
// Only start dragging if not clicking on a button // Only start dragging if not clicking on a button
const target = e.target as HTMLElement; // TODO change it to e.stopPropagation() on the buttons
if (!target.closest("mdui-button-icon")) { if (!e.target.closest("mdui-button-icon")) {
setIsDragging(true); setIsDragging(true);
setDragOffset({ setDragOffset({
x: e.clientX - pipPosition.x, x: e.clientX - pipPosition.x,
@@ -209,7 +210,7 @@ export function CallWindow() {
> >
<div className="call-header"> <div className="call-header">
<div className="window-controls"> <div className="window-controls">
<mdui-button-icon <MaterialIconButton
onClick={toggleCallMinimize} onClick={toggleCallMinimize}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"} icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className="window-control-btn" className="window-control-btn"
@@ -302,15 +303,15 @@ export function CallWindow() {
<div className="call-controls"> <div className="call-controls">
{status === "calling" && !isInitiator ? ( {status === "calling" && !isInitiator ? (
<> <>
<mdui-button-icon onClick={acceptCall} icon="call" /> <MaterialIconButton onClick={acceptCall} icon="call" />
<mdui-button-icon onClick={rejectCall} icon="call_end" /> <MaterialIconButton onClick={rejectCall} icon="call_end" />
</> </>
) : ( ) : (
<> <>
<mdui-button-icon onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} /> <MaterialIconButton onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} />
<mdui-button-icon onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} /> <MaterialIconButton onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} />
<mdui-button-icon onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} /> <MaterialIconButton onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} />
<mdui-button-icon onClick={endCall} icon="call_end" /> <MaterialIconButton onClick={endCall} icon="call_end" />
</> </>
)} )}
</div> </div>
@@ -1,6 +1,7 @@
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import useCall from "@/pages/chat/hooks/useCall"; import useCall from "@/pages/chat/hooks/useCall";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { MaterialIconButton } from "@/utils/material";
export function MinimizedCallBar() { export function MinimizedCallBar() {
const { chat, toggleCallMinimize } = useAppState(); const { chat, toggleCallMinimize } = useAppState();
@@ -49,11 +50,11 @@ export function MinimizedCallBar() {
<div className="call-actions" onClick={(e) => e.stopPropagation()}> <div className="call-actions" onClick={(e) => e.stopPropagation()}>
{call.status === "calling" && !call.isInitiator ? ( {call.status === "calling" && !call.isInitiator ? (
<mdui-button-icon onClick={endCall} icon="call_end" /> <MaterialIconButton onClick={endCall} icon="call_end" />
) : ( ) : (
<> <>
<mdui-button-icon onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} /> <MaterialIconButton onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
<mdui-button-icon onClick={endCall} icon="call_end" /> <MaterialIconButton onClick={endCall} icon="call_end" />
</> </>
)} )}
</div> </div>
@@ -1,3 +1,4 @@
import { MaterialButton } from "@/utils/material";
import "./download-app.scss"; import "./download-app.scss";
export default function DownloadAppPage() { export default function DownloadAppPage() {
@@ -11,7 +12,7 @@ export default function DownloadAppPage() {
</p> </p>
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest"> <a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
<mdui-button>Скачать на GitHub</mdui-button> <MaterialButton>Скачать на GitHub</MaterialButton>
</a> </a>
<p> <p>
@@ -19,7 +20,7 @@ export default function DownloadAppPage() {
</p> </p>
<a href="https://t.me/denis0001-dev"> <a href="https://t.me/denis0001-dev">
<mdui-button>Написать в поддержку</mdui-button> <MaterialButton>Написать в поддержку</MaterialButton>
</a> </a>
</div> </div>
</div> </div>
+30 -28
View File
@@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import "./home.scss"; import "./home.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon } from "@/utils/material";
function GitHubLink({ children }: { children: React.ReactNode }) { function GitHubLink({ children }: { children: React.ReactNode }) {
return ( return (
@@ -32,9 +33,9 @@ export default function HomePage() {
} }
const openBtn = ( const openBtn = (
<mdui-button variant="filled" onClick={handleGetStarted}> <MaterialButton variant="filled" onClick={handleGetStarted}>
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"} {isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
</mdui-button> </MaterialButton>
); );
return ( return (
@@ -48,10 +49,10 @@ export default function HomePage() {
</div> </div>
<nav className="header-nav"> <nav className="header-nav">
<GitHubLink> <GitHubLink>
<mdui-button variant="text">GitHub</mdui-button> <MaterialButton variant="text">GitHub</MaterialButton>
</GitHubLink> </GitHubLink>
<SupportLink> <SupportLink>
<mdui-button variant="text">Поддержка</mdui-button> <MaterialButton variant="text">Поддержка</MaterialButton>
</SupportLink> </SupportLink>
{openBtn} {openBtn}
@@ -73,12 +74,13 @@ export default function HomePage() {
</p> </p>
<div className="hero-actions"> <div className="hero-actions">
{openBtn} {openBtn}
{!isMobile && <mdui-button {!isMobile && (
<MaterialButton
variant="outlined" variant="outlined"
onClick={() => navigate("/register")} onClick={() => navigate("/register")}>
>
Зарегистрироваться Зарегистрироваться
</mdui-button>} </MaterialButton>
)}
</div> </div>
</div> </div>
<div className="hero-visual"> <div className="hero-visual">
@@ -122,7 +124,7 @@ export default function HomePage() {
<div className="features-grid"> <div className="features-grid">
<div className="feature-card"> <div className="feature-card">
<div className="feature-icon"> <div className="feature-icon">
<mdui-icon name="security" /> <MaterialIcon name="security" />
</div> </div>
<h4>End-to-End Шифрование</h4> <h4>End-to-End Шифрование</h4>
<p> <p>
@@ -133,7 +135,7 @@ export default function HomePage() {
<div className="feature-card"> <div className="feature-card">
<div className="feature-icon"> <div className="feature-icon">
<mdui-icon name="code" /> <MaterialIcon name="code" />
</div> </div>
<h4>100% открытый код</h4> <h4>100% открытый код</h4>
<p> <p>
@@ -144,7 +146,7 @@ export default function HomePage() {
<div className="feature-card"> <div className="feature-card">
<div className="feature-icon"> <div className="feature-icon">
<mdui-icon name="attach_file" /> <MaterialIcon name="attach_file" />
</div> </div>
<h4>Обмен Файлами</h4> <h4>Обмен Файлами</h4>
<p> <p>
@@ -155,7 +157,7 @@ export default function HomePage() {
<div className="feature-card"> <div className="feature-card">
<div className="feature-icon"> <div className="feature-icon">
<mdui-icon name="notifications" /> <MaterialIcon name="notifications" />
</div> </div>
<h4>Уведомления</h4> <h4>Уведомления</h4>
<p> <p>
@@ -166,7 +168,7 @@ export default function HomePage() {
<div className="feature-card"> <div className="feature-card">
<div className="feature-icon"> <div className="feature-icon">
<mdui-icon name="edit" /> <MaterialIcon name="edit" />
</div> </div>
<h4>Редактирование</h4> <h4>Редактирование</h4>
<p> <p>
@@ -177,7 +179,7 @@ export default function HomePage() {
<div className="feature-card"> <div className="feature-card">
<div className="feature-icon"> <div className="feature-icon">
<mdui-icon name="computer" /> <MaterialIcon name="computer" />
</div> </div>
<h4>Кроссплатформенность</h4> <h4>Кроссплатформенность</h4>
<p> <p>
@@ -205,20 +207,20 @@ export default function HomePage() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<mdui-button variant="filled"> <MaterialButton variant="filled">
<mdui-icon name="download" slot="icon" /> <MaterialIcon name="download" slot="icon" />
Скачать для ПК Скачать для ПК
</mdui-button> </MaterialButton>
</a> </a>
<mdui-button variant="outlined" onClick={() => navigate("/login")}> <MaterialButton variant="outlined" onClick={() => navigate("/login")}>
<mdui-icon name="language" slot="icon" /> <MaterialIcon name="language" slot="icon" />
Веб-версия Веб-версия
</mdui-button> </MaterialButton>
</> </>
) : ( ) : (
<mdui-button variant="filled" onClick={() => navigate("/download-app")}> <MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
Скачать приложение Скачать приложение
</mdui-button> </MaterialButton>
)} )}
</div> </div>
</div> </div>
@@ -234,21 +236,21 @@ export default function HomePage() {
</p> </p>
<div className="cta-actions"> <div className="cta-actions">
{isMobile ? ( {isMobile ? (
<mdui-button variant="filled" onClick={() => navigate("/download-app")}> <MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
Скачать приложение Скачать приложение
</mdui-button> </MaterialButton>
) : ( ) : (
<> <>
<mdui-button <MaterialButton
variant="filled" variant="filled"
onClick={() => navigate("/register")}> onClick={() => navigate("/register")}>
Создать аккаунт Создать аккаунт
</mdui-button> </MaterialButton>
<mdui-button <MaterialButton
variant="outlined" variant="outlined"
onClick={() => navigate("/login")}> onClick={() => navigate("/login")}>
Войти Войти
</mdui-button> </MaterialButton>
</> </>
)} )}
</div> </div>
@@ -1,5 +1,6 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./not-found.scss"; import "./not-found.scss";
import { MaterialButton, MaterialIcon } from "@/utils/material";
export default function NotFoundPage() { export default function NotFoundPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -14,22 +15,22 @@ export default function NotFoundPage() {
К сожалению, запрашиваемая страница не существует или была перемещена. К сожалению, запрашиваемая страница не существует или была перемещена.
</p> </p>
<div className="not-found-actions"> <div className="not-found-actions">
<mdui-button <MaterialButton
variant="filled" variant="filled"
onClick={() => navigate("/")} onClick={() => navigate("/")}
> >
На главную На главную
</mdui-button> </MaterialButton>
<mdui-button <MaterialButton
variant="outlined" variant="outlined"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
Назад Назад
</mdui-button> </MaterialButton>
</div> </div>
</div> </div>
<div className="not-found-illustration"> <div className="not-found-illustration">
<mdui-icon name="search_off"></mdui-icon> <MaterialIcon name="search_off" />
</div> </div>
</div> </div>
</div> </div>
-29
View File
@@ -1,29 +0,0 @@
/**
* @fileoverview MDUI component imports and configuration
* @description Imports all required MDUI components and sets up the theme
* @author Cursor
* @version 1.0.0
*/
import 'mdui/components/tabs';
import 'mdui/components/tab';
import 'mdui/components/tab-panel';
import 'mdui/components/list';
import 'mdui/components/list-item';
import 'mdui/components/bottom-app-bar';
import 'mdui/components/button-icon';
import 'mdui/components/fab';
import 'mdui/components/dialog';
import 'mdui/components/button';
import 'mdui/components/text-field';
import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch';
import 'mdui/components/chip';
import "mdui/mdui.css";
import 'mdui/components/circular-progress';
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
setColorScheme("#91cef4");
+145
View File
@@ -0,0 +1,145 @@
/**
* @fileoverview MDUI component imports and configuration
* @description Imports all required MDUI components and sets up the theme
* @author Cursor
* @version 1.0.0
*/
import 'mdui/components/tabs';
import 'mdui/components/tab';
import 'mdui/components/tab-panel';
import 'mdui/components/list';
import 'mdui/components/list-item';
import 'mdui/components/bottom-app-bar';
import 'mdui/components/button-icon';
import 'mdui/components/fab';
import 'mdui/components/dialog';
import 'mdui/components/button';
import 'mdui/components/text-field';
import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch';
import 'mdui/components/chip';
import "mdui/mdui.css";
import 'mdui/components/circular-progress';
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref } from 'react';
import type { TextField } from 'mdui/components/text-field';
import type { Switch } from 'mdui/components/switch';
import type { Override } from '@/core/types';
import type { Button } from 'mdui/components/button';
import type { ButtonIcon } from 'mdui/components/button-icon';
import type { Icon } from 'mdui/components/icon';
import type { Fab } from 'mdui/components/fab';
import type { Tabs } from 'mdui/components/tabs';
import type { Tab } from 'mdui/components/tab';
import type { TabPanel } from 'mdui/components/tab-panel';
import type { List } from 'mdui/components/list';
import type { ListItem } from 'mdui/components/list-item';
import type { Badge } from 'mdui/components/badge';
import type { CircularProgress } from 'mdui/components/circular-progress';
import type { BottomAppBar } from 'mdui/components/bottom-app-bar';
setColorScheme("#91cef4");
type BasePropCustomization<Tag extends keyof React.JSX.IntrinsicElements, Type> = Override<ComponentPropsWithoutRef<Tag>, {
ref?: Ref<Type>;
onInput?: (event: Override<FormEventHandler<Type>, { target: Type }>) => void;
onChange?: (event: Override<ChangeEventHandler<Type>, { target: Type }>) => void;
}>;
type NoChildren<T> = Omit<T, "children">;
// -----------------------------
// Normalized HTML element types
// -----------------------------
export type MDUITextField = Override<HTMLElement, TextField>;
export type MDUISwitch = Override<HTMLElement, Switch>;
export type MDUIButton = Override<HTMLElement, Button>;
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
export type MDUIIcon = Override<HTMLElement, Icon>;
export type MDUIFab = Override<HTMLElement, Fab>;
export type MDUITabs = Override<HTMLElement, Tabs>;
export type MDUITab = Override<HTMLElement, Tab>;
export type MDUITabPanel = Override<HTMLElement, TabPanel>;
export type MDUIList = Override<HTMLElement, List>;
export type MDUIListItem = Override<HTMLElement, ListItem>;
export type MDUIBadge = Override<HTMLElement, Badge>;
export type MDUICircularProgress = Override<HTMLElement, CircularProgress>;
export type MDUIBottomAppBar = Override<HTMLElement, BottomAppBar>;
// -------------------------------------------
// MDUI components wrapped in React components
// -------------------------------------------
export type MaterialTextFieldProps = BasePropCustomization<"mdui-text-field", MDUITextField>;
export function MaterialTextField(props: MaterialTextFieldProps) {
return <mdui-text-field autocomplete="off" {...props as ComponentProps<"mdui-text-field">} />
}
export type MaterialSwitchProps = BasePropCustomization<"mdui-switch", MDUISwitch>;
export function MaterialSwitch(props: MaterialSwitchProps) {
return <mdui-switch {...props as ComponentProps<"mdui-switch">} />
}
export type MaterialButtonProps = BasePropCustomization<"mdui-button", MDUIButton>;
export function MaterialButton(props: MaterialButtonProps) {
return <mdui-button {...props as ComponentProps<"mdui-button">} />
}
export type MaterialIconButtonProps = NoChildren<BasePropCustomization<"mdui-button-icon", MDUIButtonIcon>>;
export function MaterialIconButton(props: MaterialIconButtonProps) {
return <mdui-button-icon {...props as ComponentProps<"mdui-button-icon">} />
}
export type MaterialIconProps = NoChildren<BasePropCustomization<"mdui-icon", MDUIIcon>>;
export function MaterialIcon(props: MaterialIconProps) {
return <mdui-icon {...props as ComponentProps<"mdui-icon">} />
}
export type MaterialFabProps = NoChildren<BasePropCustomization<"mdui-fab", MDUIFab>>;
export function MaterialFab(props: MaterialFabProps) {
return <mdui-fab {...props as ComponentProps<"mdui-fab">} />
}
export type MaterialTabsProps = BasePropCustomization<"mdui-tabs", MDUITabs>;
export function MaterialTabs(props: MaterialTabsProps) {
return <mdui-tabs {...props as ComponentProps<"mdui-tabs">} />
}
export type MaterialTabProps = BasePropCustomization<"mdui-tab", MDUITab>;
export function MaterialTab(props: MaterialTabProps) {
return <mdui-tab {...props as ComponentProps<"mdui-tab">} />
}
export type MaterialTabPanelProps = BasePropCustomization<"mdui-tab-panel", MDUITabPanel>;
export function MaterialTabPanel(props: MaterialTabPanelProps) {
return <mdui-tab-panel {...props as ComponentProps<"mdui-tab-panel">} />
}
export type MaterialListProps = BasePropCustomization<"mdui-list", MDUIList>;
export function MaterialList(props: MaterialListProps) {
return <mdui-list {...props as ComponentProps<"mdui-list">} />
}
export type MaterialListItemProps = BasePropCustomization<"mdui-list-item", MDUIListItem>;
export function MaterialListItem(props: MaterialListItemProps) {
return <mdui-list-item {...props as ComponentProps<"mdui-list-item">} />
}
export type MaterialBadgeProps = BasePropCustomization<"mdui-badge", MDUIBadge>;
export function MaterialBadge(props: MaterialBadgeProps) {
return <mdui-badge {...props as ComponentProps<"mdui-badge">} />
}
export type MaterialCircularProgressProps = NoChildren<BasePropCustomization<"mdui-circular-progress", MDUICircularProgress>>;
export function MaterialCircularProgress(props: MaterialCircularProgressProps) {
return <mdui-circular-progress {...props as ComponentProps<"mdui-circular-progress">} />
}
export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-bar", MDUIBottomAppBar>;
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
}
+27
View File
@@ -1,2 +1,29 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="mdui/jsx.en.d.ts" /> /// <reference types="mdui/jsx.en.d.ts" />
/// <reference types="./core/types.d.ts" />
declare global {
namespace React {
// Augment React synthetic events to provide typed target for ALL HTML elements
interface SyntheticEvent<T = Element, E = Event> {
target: EventTarget & T;
}
}
// Augment DOM event listeners to provide typed target for ALL elements
// This works by augmenting the base Element interface which covers all HTML, SVG, etc.
interface Element {
addEventListener<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: Element, ev: HTMLElementEventMap[K] & { target: Element }) => any,
options?: boolean | AddEventListenerOptions
): void;
removeEventListener<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: Element, ev: HTMLElementEventMap[K] & { target: Element }) => any,
options?: boolean | EventListenerOptions
): void;
}
}
export {};