mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Create new Material components, redesign the chat UI, add security settings
This commit is contained in:
Vendored
+3
@@ -69,6 +69,9 @@
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -157,6 +157,7 @@ class DeviceSession(Base):
|
||||
raw_user_agent = Column(Text, nullable=True)
|
||||
|
||||
# Parsed fields
|
||||
device_name = Column(String(128), nullable=True)
|
||||
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
|
||||
os_name = Column(String(64), nullable=True)
|
||||
os_version = Column(String(64), nullable=True)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
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 utils import create_token, get_password_hash, verify_password
|
||||
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
|
||||
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 "")
|
||||
session_id = uuid.uuid4().hex
|
||||
|
||||
device = DeviceSession(
|
||||
user_id=user.id,
|
||||
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"),
|
||||
os_name=(ua.os.family 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
|
||||
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 "")
|
||||
session_id = uuid.uuid4().hex
|
||||
device = DeviceSession(
|
||||
user_id=new_user.id,
|
||||
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"),
|
||||
os_name=(ua.os.family or None),
|
||||
os_version=(ua.os.version_string or None),
|
||||
@@ -261,9 +266,19 @@ def delete_user_as_owner(
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||||
current_user: User = Depends(get_current_user),
|
||||
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.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
@@ -28,7 +28,7 @@ def list_devices(
|
||||
current_session_id = _get_current_session_id(credentials)
|
||||
sessions = (
|
||||
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())
|
||||
.all()
|
||||
)
|
||||
@@ -37,6 +37,7 @@ def list_devices(
|
||||
{
|
||||
"session_id": s.session_id,
|
||||
"device_type": s.device_type,
|
||||
"device_name": s.device_name,
|
||||
"os_name": s.os_name,
|
||||
"os_version": s.os_version,
|
||||
"browser_name": s.browser_name,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
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
|
||||
@@ -151,7 +151,6 @@ export function getAuthToken(): string | null {
|
||||
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
|
||||
*/
|
||||
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
|
||||
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
|
||||
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getAuthHeaders } from "@/core/api/authApi";
|
||||
|
||||
export interface DeviceInfo {
|
||||
session_id: string;
|
||||
device_name?: string;
|
||||
device_type?: string;
|
||||
os_name?: 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} />
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import "./css/searchBar.scss";
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder: string;
|
||||
@@ -57,9 +58,9 @@ export default function SearchBar({
|
||||
function renderIcon(icon: string | React.ReactNode | undefined, defaultIcon?: string) {
|
||||
if (icon === null) return null;
|
||||
if (!icon) {
|
||||
return defaultIcon ? <mdui-icon name={defaultIcon}></mdui-icon> : null;
|
||||
return defaultIcon ? <MaterialIcon name={defaultIcon} /> : null;
|
||||
} else if (typeof icon === 'string') {
|
||||
return <mdui-icon name={icon}></mdui-icon>;
|
||||
return <MaterialIcon name={icon} />;
|
||||
} else {
|
||||
return icon;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { checkUserSimilarity } from "@/core/api/profileApi";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
verified: boolean;
|
||||
@@ -33,7 +34,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro
|
||||
if (verified) {
|
||||
return (
|
||||
<span className={`${className} verified`} title="Подтверждённый аккаунт">
|
||||
<mdui-icon name="verified--filled" />
|
||||
<MaterialIcon name="verified--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +42,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro
|
||||
if (isSimilarToVerified) {
|
||||
return (
|
||||
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
|
||||
<mdui-icon name="warning" />
|
||||
<MaterialIcon name="warning--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { verifyUser } from "@/core/api/profileApi";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
|
||||
interface VerifyButtonProps {
|
||||
userId: number;
|
||||
@@ -34,13 +35,13 @@ export function VerifyButton({ userId, verified, onVerificationChange }: VerifyB
|
||||
}
|
||||
|
||||
return (
|
||||
<mdui-button
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
loading={isVerifying}
|
||||
onClick={handleVerifyToggle}
|
||||
title={verified ? "Снять подтверждение" : "Подтвердить аккаунт"}
|
||||
>
|
||||
{verified ? "Отменить подтверждение" : "Подтвердить"}
|
||||
</mdui-button>
|
||||
</MaterialButton>
|
||||
);
|
||||
}
|
||||
Vendored
+8
-1
@@ -630,4 +630,11 @@ export interface StopDmTypingRequest extends WebSocketMessage {
|
||||
data: {
|
||||
recipientId: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -------------
|
||||
// Utility types
|
||||
// -------------
|
||||
|
||||
export type Override<TBase, TExt> = Omit<TBase, keyof TExt> & TExt;
|
||||
@@ -7,12 +7,12 @@ import { API_BASE_URL } from "@/core/config";
|
||||
import { useRef } from "react";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialTextField } from "@/core/components/MaterialTextField";
|
||||
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 useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
import { MaterialButton, MaterialTextField } from "@/utils/material";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
@@ -114,6 +114,7 @@ export default function LoginPage() {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
|
||||
<MaterialTextField
|
||||
label="@Имя пользователя"
|
||||
id="login-username"
|
||||
@@ -136,7 +137,7 @@ export default function LoginPage() {
|
||||
required
|
||||
ref={passwordElement} />
|
||||
|
||||
<mdui-button type="submit">Войти</mdui-button>
|
||||
<MaterialButton type="submit">Войти</MaterialButton>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TextField } from "mdui/components/text-field";
|
||||
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialTextField } from "@/core/components/MaterialTextField";
|
||||
import { MaterialButton, MaterialTextField } from "@/utils/material";
|
||||
import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./auth.scss";
|
||||
@@ -156,7 +156,7 @@ export default function RegisterPage() {
|
||||
required
|
||||
ref={confirmPasswordElement} />
|
||||
|
||||
<mdui-button type="submit">Зарегистрироваться</mdui-button>
|
||||
<MaterialButton type="submit">Зарегистрироваться</MaterialButton>
|
||||
</form>
|
||||
|
||||
<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
@use "right-panel" as *;
|
||||
|
||||
.chat-input-wrapper {
|
||||
position: relative;
|
||||
margin: 0 10px 10px 10px;
|
||||
margin: 0 10px - $scrollbar-width 10px 10px;
|
||||
position: sticky;
|
||||
bottom: 10px;
|
||||
z-index: 1;
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.chat-main {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,76 +2,27 @@
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.chat-main {
|
||||
.chat-header {
|
||||
padding: 16px;
|
||||
background: rgba($color-dark-surface-container, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
$scrollbar-width: 8px;
|
||||
|
||||
.chat-wrapper {
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.chat-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 20px rgba($color-dark-primary, 0.1);
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
.chat-header-avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 20%;
|
||||
object-fit: cover;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
.info-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h4 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #718096;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
justify-content: end;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
top: 2%;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 10px 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
// Fancy floating thin scrollbar with invisible transparent margin
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba($color-dark-primary, 0.3) transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
width: $scrollbar-width;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
@@ -79,48 +30,136 @@
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
border-radius: 20px;
|
||||
background: rgba($color-dark-primary, 0.3);
|
||||
border-radius: 4px;
|
||||
margin: 2px;
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-dark-primary, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
z-index: 100;
|
||||
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.file-overlay-wrapper {
|
||||
.chat-header {
|
||||
padding: 16px;
|
||||
margin: 10px 10px - $scrollbar-width 0 10px;
|
||||
background: rgba($color-dark-surface-container, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 30px;
|
||||
outline: 3px dashed $color-dark-primary;
|
||||
outline-offset: -20px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.file-overlay-inner {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
z-index: 5;
|
||||
|
||||
.chat-header-avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 20%;
|
||||
object-fit: cover;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
flex: 1;
|
||||
|
||||
.info-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h4 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #718096;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
justify-content: end;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
top: 2%;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 10px 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.file-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
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%;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,5 @@
|
||||
@use "callWindow";
|
||||
@use "profile-dialog";
|
||||
@use "typing-indicators";
|
||||
@use "suspension-dialog";
|
||||
@use "suspension-dialog";
|
||||
@use "changePasswordDialog";
|
||||
@@ -12,6 +12,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { OnlineStatus } from "./right/OnlineStatus";
|
||||
import { Input } from "@/core/components/Input";
|
||||
import { StyledDialog } from "@/core/components/StyledDialog";
|
||||
import { MaterialButton, MaterialFab, MaterialIcon } from "@/utils/material";
|
||||
|
||||
interface SectionProps {
|
||||
type: string;
|
||||
@@ -55,7 +56,7 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
|
||||
|
||||
return (
|
||||
<div className={`section ${type} ${error ? 'error' : ''}`}>
|
||||
<mdui-icon name={icon} />
|
||||
<MaterialIcon name={icon} />
|
||||
<div className="content-container">
|
||||
<label className="label">{label}</label>
|
||||
{valueComponent}
|
||||
@@ -442,12 +443,11 @@ export function ProfileDialog() {
|
||||
className="profile-dialog"
|
||||
afterChildren={
|
||||
currentData.isOwnProfile && (
|
||||
<mdui-fab
|
||||
<MaterialFab
|
||||
icon="check"
|
||||
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
disabled={isSaving} />
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -457,16 +457,16 @@ export function ProfileDialog() {
|
||||
src={currentData.profilePicture || defaultAvatar}
|
||||
alt="Profile Picture"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
e.target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentData.isOwnProfile && (
|
||||
<div
|
||||
className="profile-picture-edit-overlay"
|
||||
onClick={handleProfilePictureClick}
|
||||
>
|
||||
<mdui-icon name="camera_alt--filled" />
|
||||
<MaterialIcon name="camera_alt--filled" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -481,6 +481,7 @@ export function ProfileDialog() {
|
||||
onChange={handleDisplayNameChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="Имя" />
|
||||
|
||||
<StatusBadge
|
||||
verified={currentData.verified || false}
|
||||
userId={currentData.userId}
|
||||
@@ -502,22 +503,22 @@ export function ProfileDialog() {
|
||||
<div className="admin-actions-section">
|
||||
<h3 className="admin-actions-header">Admin Actions</h3>
|
||||
<div className="admin-buttons">
|
||||
<mdui-button
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
color="error"
|
||||
icon={currentData.suspended ? "check_circle--filled" : "block--filled"}
|
||||
onClick={handleSuspend}
|
||||
>
|
||||
{currentData.suspended ? "Unsuspend Account" : "Suspend Account"}
|
||||
</mdui-button>
|
||||
<mdui-button
|
||||
</MaterialButton>
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
color="error"
|
||||
icon="delete_forever--filled"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete Account
|
||||
</mdui-button>
|
||||
</MaterialButton>
|
||||
<VerifyButton
|
||||
userId={currentData.userId!}
|
||||
verified={currentData.verified || false}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { StyledDialog } from "@/core/components/StyledDialog";
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
|
||||
interface SuspensionDialogProps {
|
||||
reason: string;
|
||||
@@ -13,7 +14,7 @@ export function SuspensionDialog({ reason, open, onOpenChange }: SuspensionDialo
|
||||
onOpenChange={onOpenChange}>
|
||||
<div className="suspension-dialog-content">
|
||||
<div className="suspension-icon-section">
|
||||
<mdui-icon name="block--filled" className="suspension-icon" />
|
||||
<MaterialIcon name="block--filled" className="suspension-icon" />
|
||||
</div>
|
||||
|
||||
<div className="suspension-text">
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { useAppState, type ChatTabs } from "@/pages/chat/state";
|
||||
import { UnifiedChatsList } from "./UnifiedChatsList";
|
||||
import type { Tabs } from "mdui/components/tabs";
|
||||
import { MaterialTab, MaterialTabPanel, MaterialTabs } from "@/utils/material";
|
||||
|
||||
export function ChatTabs() {
|
||||
const { chat, setActiveTab } = useAppState();
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs
|
||||
<MaterialTabs
|
||||
value={chat.activeTab}
|
||||
full-width
|
||||
onChange={(e) => setActiveTab((e.target as Tabs).value as ChatTabs)}>
|
||||
<mdui-tab value="chats">
|
||||
onChange={(e) => setActiveTab(e.target.value as ChatTabs)}>
|
||||
<MaterialTab value="chats">
|
||||
Чаты
|
||||
</mdui-tab>
|
||||
<mdui-tab value="channels">
|
||||
</MaterialTab>
|
||||
<MaterialTab value="channels">
|
||||
Каналы
|
||||
</mdui-tab>
|
||||
<mdui-tab value="contacts">
|
||||
</MaterialTab>
|
||||
<MaterialTab value="contacts">
|
||||
Контакты
|
||||
</mdui-tab>
|
||||
</MaterialTab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<MaterialTabPanel slot="panel" value="chats">
|
||||
<UnifiedChatsList />
|
||||
</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</MaterialTabPanel>
|
||||
<MaterialTabPanel slot="panel" value="channels">Скоро будет...</MaterialTabPanel>
|
||||
<MaterialTabPanel slot="panel" value="contacts">Скоро будет...</MaterialTabPanel>
|
||||
</MaterialTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,29 +4,25 @@ import { SettingsDialog } from "./settings/SettingsDialog";
|
||||
import { UsernameSearch } from "./UsernameSearch";
|
||||
import { ChatTabs } from "./ChatTabs";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { MaterialBottomAppBar, MaterialFab, MaterialIconButton } from "@/utils/material";
|
||||
|
||||
function BottomAppBar() {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
const { logout } = useAppState();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<mdui-bottom-app-bar>
|
||||
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
|
||||
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
|
||||
<MaterialBottomAppBar>
|
||||
<MaterialIconButton icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
|
||||
<MaterialIconButton icon="group_add--filled" />
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
<mdui-button-icon
|
||||
<MaterialIconButton
|
||||
icon="logout--filled"
|
||||
id="logout-btn"
|
||||
onClick={handleLogout}
|
||||
title="Выйти"
|
||||
></mdui-button-icon>
|
||||
<mdui-fab icon="edit--filled"></mdui-fab>
|
||||
</mdui-bottom-app-bar>
|
||||
onClick={logout}
|
||||
title="Выйти" />
|
||||
<MaterialFab icon="edit--filled" />
|
||||
</MaterialBottomAppBar>
|
||||
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { websocket } from "@/core/websocket";
|
||||
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";
|
||||
|
||||
interface PublicChat {
|
||||
id: string;
|
||||
@@ -223,17 +224,15 @@ export function UnifiedChatsList() {
|
||||
}
|
||||
|
||||
if (isLoadingUsers) {
|
||||
return (
|
||||
<mdui-circular-progress />
|
||||
);
|
||||
return <MaterialCircularProgress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<mdui-list>
|
||||
<MaterialList>
|
||||
{allChats.map((chat) => {
|
||||
if (chat.type === "public") {
|
||||
return (
|
||||
<mdui-list-item
|
||||
<MaterialListItem
|
||||
key={`public-${chat.id}`}
|
||||
headline={chat.name}
|
||||
onClick={() => handlePublicChatClick(chat.name)}
|
||||
@@ -255,11 +254,11 @@ export function UnifiedChatsList() {
|
||||
objectFit: "cover"
|
||||
}}
|
||||
/>
|
||||
</mdui-list-item>
|
||||
</MaterialListItem>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<mdui-list-item
|
||||
<MaterialListItem
|
||||
key={`dm-${chat.id}`}
|
||||
headline={chat.display_name}
|
||||
onClick={() => handleDMClick(chat)}
|
||||
@@ -288,20 +287,20 @@ export function UnifiedChatsList() {
|
||||
display: "block"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
e.target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<OnlineIndicator userId={chat.id} />
|
||||
</div>
|
||||
{chat.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
<MaterialBadge slot="end-icon">
|
||||
{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 defaultAvatar from "@/images/default-avatar.png";
|
||||
import SearchBar from "@/core/components/SearchBar";
|
||||
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
|
||||
|
||||
interface SearchUser extends User {
|
||||
publicKey?: string | null;
|
||||
@@ -121,7 +122,7 @@ export function UsernameSearch() {
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
leftIcon={isExpanded ? (
|
||||
<mdui-button-icon
|
||||
<MaterialIconButton
|
||||
className="back-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -134,7 +135,7 @@ export function UsernameSearch() {
|
||||
>
|
||||
{isSearching && (
|
||||
<div className="search-loading">
|
||||
<mdui-circular-progress value={0}></mdui-circular-progress>
|
||||
<MaterialCircularProgress />
|
||||
<span>Поиск...</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -146,9 +147,9 @@ export function UsernameSearch() {
|
||||
)}
|
||||
|
||||
{!isSearching && searchResults.length > 0 && (
|
||||
<mdui-list>
|
||||
<MaterialList>
|
||||
{searchResults.map((searchUser) => (
|
||||
<mdui-list-item
|
||||
<MaterialListItem
|
||||
key={searchUser.id}
|
||||
headline={searchUser.username}
|
||||
onClick={() => handleUserClick(searchUser)}
|
||||
@@ -174,14 +175,14 @@ export function UsernameSearch() {
|
||||
display: "block"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
e.target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<OnlineIndicator userId={searchUser.id} />
|
||||
</div>
|
||||
</mdui-list-item>
|
||||
</MaterialListItem>
|
||||
))}
|
||||
</mdui-list>
|
||||
</MaterialList>
|
||||
)}
|
||||
|
||||
{!isSearching && searchQuery.length < 2 && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MaterialButton, MaterialIconButton } from "@/utils/material";
|
||||
import "./css/cropper-dialog.scss";
|
||||
|
||||
export function CropperDialog() {
|
||||
@@ -6,14 +7,14 @@ export function CropperDialog() {
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
|
||||
<MaterialIconButton icon="close" id="cropper-close" />
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<div id="cropper-area"></div>
|
||||
</div>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
|
||||
<mdui-button id="crop-save">Сохранить</mdui-button>
|
||||
<MaterialButton id="crop-cancel" variant="outlined">Отмена</MaterialButton>
|
||||
<MaterialButton id="crop-save">Сохранить</MaterialButton>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Size2D, Rect } from "@/core/types";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
|
||||
interface ImageCropperProps {
|
||||
onCrop: (croppedImageData: string) => void;
|
||||
@@ -180,12 +181,12 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
|
||||
alt="Crop source"
|
||||
/>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button onClick={handleCrop} disabled={!isLoaded}>
|
||||
<MaterialButton onClick={handleCrop} disabled={!isLoaded}>
|
||||
Обрезать
|
||||
</mdui-button>
|
||||
<mdui-button variant="outlined" onClick={onCancel}>
|
||||
</MaterialButton>
|
||||
<MaterialButton variant="outlined" onClick={onCancel}>
|
||||
Отмена
|
||||
</mdui-button>
|
||||
</MaterialButton>
|
||||
</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 { isElectron } from "@/core/electron/electron";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import type { Switch } from "mdui/components/switch";
|
||||
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 { useImmer } from "use-immer";
|
||||
import { MaterialButton, MaterialIconButton, MaterialList, MaterialListItem, MaterialSwitch } from "@/utils/material";
|
||||
|
||||
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const [activePanel, setActivePanel] = useState("notifications-settings");
|
||||
@@ -18,10 +18,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const user = useAppState(state => state.user);
|
||||
const logout = useAppState(state => state.logout);
|
||||
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
|
||||
const [cpCurrent, setCpCurrent] = useState("");
|
||||
const [cpNext, setCpNext] = useState("");
|
||||
const [cpConfirm, setCpConfirm] = useState("");
|
||||
const [cpLogoutAll, setCpLogoutAll] = useState(true);
|
||||
const [cpOpen, setCpOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPushSupported(isSupported());
|
||||
@@ -79,131 +76,106 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="settings-dialog">
|
||||
<div id="settings-dialog-inner">
|
||||
<div className="header">
|
||||
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)}></mdui-button-icon>
|
||||
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
|
||||
</div>
|
||||
<div id="settings-menu">
|
||||
<mdui-list>
|
||||
<mdui-list-item
|
||||
icon="notifications--filled"
|
||||
rounded
|
||||
active={activePanel === "notifications-settings"}
|
||||
onClick={() => handlePanelChange("notifications-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Уведомления
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="security--filled"
|
||||
rounded
|
||||
active={activePanel === "security-settings"}
|
||||
onClick={() => handlePanelChange("security-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Безопасность
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="devices--filled"
|
||||
rounded
|
||||
active={activePanel === "devices-settings"}
|
||||
onClick={() => handlePanelChange("devices-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Устройства
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="info--filled"
|
||||
rounded
|
||||
active={activePanel === "about-settings"}
|
||||
onClick={() => handlePanelChange("about-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
О приложении
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
<div className="screen">
|
||||
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
|
||||
<h3>Уведомления</h3>
|
||||
{pushSupported && (
|
||||
<mdui-switch
|
||||
checked={pushNotificationsEnabled}
|
||||
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
|
||||
>
|
||||
Push уведомления
|
||||
</mdui-switch>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
|
||||
<h3>Безопасность</h3>
|
||||
<form onSubmit={async (e) => {
|
||||
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 id="devices-settings" className={`settings-panel ${activePanel === "devices-settings" ? "active" : ""}`}>
|
||||
<h3>Устройства</h3>
|
||||
<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>
|
||||
<mdui-button variant="outlined" onClick={async () => { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве</mdui-button>
|
||||
<>
|
||||
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="settings-dialog">
|
||||
<div id="settings-dialog-inner">
|
||||
<div className="header">
|
||||
<MaterialIconButton icon="close" id="settings-close" onClick={() => onOpenChange(false)}></MaterialIconButton>
|
||||
<div className="title">Настройки</div>
|
||||
</div>
|
||||
<div id="settings-menu">
|
||||
<MaterialList>
|
||||
<MaterialListItem
|
||||
icon="notifications--filled"
|
||||
rounded
|
||||
active={activePanel === "notifications-settings"}
|
||||
onClick={() => handlePanelChange("notifications-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Уведомления
|
||||
</MaterialListItem>
|
||||
<MaterialListItem
|
||||
icon="security--filled"
|
||||
rounded
|
||||
active={activePanel === "security-settings"}
|
||||
onClick={() => handlePanelChange("security-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Безопасность
|
||||
</MaterialListItem>
|
||||
<MaterialListItem
|
||||
icon="devices--filled"
|
||||
rounded
|
||||
active={activePanel === "devices-settings"}
|
||||
onClick={() => handlePanelChange("devices-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Устройства
|
||||
</MaterialListItem>
|
||||
<MaterialListItem
|
||||
icon="info--filled"
|
||||
rounded
|
||||
active={activePanel === "about-settings"}
|
||||
onClick={() => handlePanelChange("about-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
О приложении
|
||||
</MaterialListItem>
|
||||
</MaterialList>
|
||||
<div className="screen">
|
||||
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
|
||||
<h3>Уведомления</h3>
|
||||
{pushSupported && (
|
||||
<MaterialSwitch
|
||||
checked={pushNotificationsEnabled}
|
||||
onInput={(e) => handlePushNotificationToggle(e.target.checked)}>
|
||||
Push уведомления
|
||||
</MaterialSwitch>
|
||||
)}
|
||||
</div>
|
||||
<mdui-list>
|
||||
{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); }}>
|
||||
<div slot="headline">{d.browser_name || "Браузер"} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}</div>
|
||||
<div slot="description">Последняя активность: {d.last_seen || "—"}</div>
|
||||
</mdui-list-item>
|
||||
))}
|
||||
</mdui-list>
|
||||
</div>
|
||||
|
||||
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
|
||||
<h3>О приложении</h3>
|
||||
<p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p>
|
||||
<p><span className="product-name">{PRODUCT_NAME}</span></p>
|
||||
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
|
||||
<h3>Безопасность</h3>
|
||||
<MaterialButton variant="tonal" onClick={() => setCpOpen(true)}>Изменить пароль</MaterialButton>
|
||||
</div>
|
||||
|
||||
<div id="devices-settings" className={`settings-panel ${activePanel === "devices-settings" ? "active" : ""}`}>
|
||||
<h3>Устройства</h3>
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
|
||||
<MaterialButton variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</MaterialButton>
|
||||
<MaterialButton variant="outlined" onClick={async () => { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве</MaterialButton>
|
||||
</div>
|
||||
<MaterialList>
|
||||
{devices.map((d) => (
|
||||
<MaterialListItem
|
||||
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>
|
||||
</MaterialListItem>
|
||||
))}
|
||||
</MaterialList>
|
||||
</div>
|
||||
|
||||
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
|
||||
<h3>О приложении</h3>
|
||||
<p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p>
|
||||
<p><span className="product-name">{PRODUCT_NAME}</span></p>
|
||||
</div>
|
||||
</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 { useImmer } from "use-immer";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material";
|
||||
|
||||
interface ChatInputWrapperProps {
|
||||
onSendMessage: (message: string, files: File[]) => void;
|
||||
@@ -155,12 +156,12 @@ export function ChatInputWrapper(
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="reply-preview contextual-preview">
|
||||
<mdui-icon name="edit" />
|
||||
<MaterialIcon name="edit" />
|
||||
<Quote className="reply-content contextual-content" background="surfaceContainer">
|
||||
<span className="reply-username">{editingMessage!.username}</span>
|
||||
<span className="reply-text">{editingMessage!.content}</span>
|
||||
</Quote>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon>
|
||||
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearEdit}></MaterialIconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -175,12 +176,12 @@ export function ChatInputWrapper(
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="reply-preview contextual-preview">
|
||||
<mdui-icon name="reply" />
|
||||
<MaterialIcon name="reply" />
|
||||
<Quote className="reply-content contextual-content" background="surfaceContainer">
|
||||
<span className="reply-username">{replyTo!.username}</span>
|
||||
<span className="reply-text">{replyTo!.content}</span>
|
||||
</Quote>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
|
||||
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearReply}></MaterialIconButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -195,7 +196,7 @@ export function ChatInputWrapper(
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="attachments-preview contextual-preview">
|
||||
<mdui-icon name="attach_file" />
|
||||
<MaterialIcon name="attach_file" />
|
||||
<div className="attachments-chips">
|
||||
{selectedFiles.map((file, i) => (
|
||||
<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>
|
||||
</mdui-chip>
|
||||
))}
|
||||
</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>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="chat-input">
|
||||
<div className="left-buttons">
|
||||
<mdui-button-icon
|
||||
<MaterialIconButton
|
||||
icon="mood"
|
||||
onClick={handleEmojiButtonClick}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
@@ -240,7 +241,7 @@ export function ChatInputWrapper(
|
||||
onTextChange={handleMessageChange}
|
||||
onEnter={handleSubmit} />
|
||||
<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">
|
||||
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
|
||||
</button>
|
||||
@@ -250,7 +251,7 @@ export function ChatInputWrapper(
|
||||
<MaterialDialog open={errorOpen} onOpenChange={setErrorOpen} close-on-overlay-click close-on-esc>
|
||||
<div slot="headline">Ошибка</div>
|
||||
<div>Общий размер вложений превышает 4 ГБ.</div>
|
||||
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
|
||||
<MaterialButton slot="action" onClick={() => setErrorOpen(false)}>Закрыть</MaterialButton>
|
||||
</MaterialDialog>
|
||||
|
||||
<EmojiMenu
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages?: MessageType[];
|
||||
@@ -147,8 +148,8 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
headline="Удалить сообщение?"
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}>
|
||||
<mdui-button slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</mdui-button>
|
||||
<mdui-button slot="action" variant="filled" onClick={confirmDelete}>Удалить</mdui-button>
|
||||
<MaterialButton slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</MaterialButton>
|
||||
<MaterialButton slot="action" variant="filled" onClick={confirmDelete}>Удалить</MaterialButton>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Context Menu */}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ub64 } from "@/utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
import { parseProfileLink } from "@/core/profileLinks";
|
||||
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
reactions?: Reaction[];
|
||||
@@ -415,10 +416,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
}
|
||||
|
||||
async function handleLinkClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target.tagName === 'A') {
|
||||
const profileLink = parseProfileLink((target as HTMLAnchorElement).href);
|
||||
if (e.target.tagName === 'A') {
|
||||
const link = (e.target as unknown as HTMLAnchorElement).href;
|
||||
const profileLink = parseProfileLink(link);
|
||||
|
||||
if (profileLink) {
|
||||
e.preventDefault();
|
||||
@@ -443,7 +443,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
isOwnProfile: userProfile.id === user.currentUser?.id
|
||||
});
|
||||
} else {
|
||||
throw new Error("Invalid link: " + (target as HTMLAnchorElement).href);
|
||||
throw new Error(`Invalid link: ${link}`);
|
||||
}
|
||||
} catch (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)}
|
||||
alt={message.username}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
e.target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -517,7 +516,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
onClick={handleLinkClick} />
|
||||
|
||||
{message.files && message.files.length > 0 && (
|
||||
<mdui-list className="message-attachments">
|
||||
<MaterialList className="message-attachments">
|
||||
{message.files.map((file, idx) => {
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
const isEncryptedDm = Boolean(isDm && file.encrypted);
|
||||
@@ -542,7 +541,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
/>
|
||||
{(!loadedImages.has(file.path) || isSending) && (
|
||||
<div className="loading-overlay">
|
||||
<mdui-circular-progress />
|
||||
<MaterialCircularProgress />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -554,18 +553,18 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
await downloadFile(file);
|
||||
}}
|
||||
>
|
||||
<mdui-list-item>
|
||||
<MaterialListItem>
|
||||
<span className="with-icon-gap">
|
||||
{isDownloading ? <mdui-circular-progress /> : null}
|
||||
{isDownloading ? <MaterialCircularProgress /> : null}
|
||||
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
|
||||
</span>
|
||||
</mdui-list-item>
|
||||
</MaterialListItem>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</mdui-list>
|
||||
</MaterialList>
|
||||
)}
|
||||
|
||||
<Reactions
|
||||
@@ -585,7 +584,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
{isAuthor && message.runtimeData?.sendingState && (
|
||||
<span className="message-status-indicator">
|
||||
{message.runtimeData.sendingState.status === 'sending' && (
|
||||
<mdui-circular-progress style={{ width: '16px', height: '16px' }} />
|
||||
<MaterialCircularProgress style={{ width: '16px', height: '16px' }} />
|
||||
)}
|
||||
{message.runtimeData.sendingState.status === 'failed' && (
|
||||
<span className="material-symbols error-icon">error</span>
|
||||
@@ -617,13 +616,13 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
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 ? (
|
||||
<div className="progress-wrapper">
|
||||
<mdui-circular-progress />
|
||||
<MaterialCircularProgress />
|
||||
</div>
|
||||
) : (
|
||||
<mdui-button-icon icon="download" onClick={downloadImage} />
|
||||
<MaterialIconButton icon="download" onClick={downloadImage} />
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TypingIndicator } from "./TypingIndicator";
|
||||
import { OnlineStatus } from "./OnlineStatus";
|
||||
import { typingManager } from "@/core/typingManager";
|
||||
import { PublicChatPanel } from "./panels/PublicChatPanel";
|
||||
import { MaterialIcon, MaterialIconButton } from "@/utils/material";
|
||||
|
||||
interface MessagePanelRendererProps {
|
||||
panel: MessagePanel | null;
|
||||
@@ -199,198 +200,202 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={panelKey}
|
||||
ref={messagePanelRef}
|
||||
className="chat-main"
|
||||
id="chat-inner"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onDragEnter={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current += 1;
|
||||
// Only show overlay when actual files are dragged
|
||||
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
|
||||
if (hasFiles) setIsDragging(true);
|
||||
} : undefined}
|
||||
onDragOver={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
} : undefined}
|
||||
onDragLeave={panel ? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) setIsDragging(false);
|
||||
} : undefined}
|
||||
onDrop={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length > 0 && addFilesRef.current) {
|
||||
addFilesRef.current(files);
|
||||
}
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
} : undefined}>
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<ChatHeaderText panel={panel} />
|
||||
className="chat-wrapper"
|
||||
>
|
||||
<div
|
||||
ref={messagePanelRef}
|
||||
className="chat-main"
|
||||
id="chat-inner"
|
||||
onDragEnter={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current += 1;
|
||||
// Only show overlay when actual files are dragged
|
||||
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
|
||||
if (hasFiles) setIsDragging(true);
|
||||
} : undefined}
|
||||
onDragOver={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
} : undefined}
|
||||
onDragLeave={panel ? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) setIsDragging(false);
|
||||
} : undefined}
|
||||
onDrop={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length > 0 && addFilesRef.current) {
|
||||
addFilesRef.current(files);
|
||||
}
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
} : undefined}>
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<ChatHeaderText panel={panel} />
|
||||
</div>
|
||||
{panel?.isDm() && (
|
||||
<MaterialIconButton onClick={handleCallClick} icon="call--filled" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{panel?.isDm() && (
|
||||
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
|
||||
|
||||
{panelState?.isLoading ? (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Загрузка сообщений...
|
||||
</div>
|
||||
</div>
|
||||
) : panelState && panel ? (
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
|
||||
onReplySelect={(message) => {
|
||||
if (editMessage || editVisible) {
|
||||
setPendingAction({ type: "reply", message: message });
|
||||
setEditVisible(false); // onCloseEdit will apply pending
|
||||
} else {
|
||||
setReplyTo(message);
|
||||
}
|
||||
}}
|
||||
onEditSelect={(message) => {
|
||||
if (replyTo || replyToVisible) {
|
||||
setPendingAction({ type: "edit", message: message });
|
||||
setReplyToVisible(false); // onCloseReply will apply pending
|
||||
} else {
|
||||
setEditMessage(message);
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => panel.handleDeleteMessage(id)}
|
||||
onRetryMessage={(id) => panel.retryMessage(id)}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
) : (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Выберите чат на боковой панели, чтобы начать переписку
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel && (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isDragging && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="file-overlay"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="file-overlay-wrapper">
|
||||
<div className="file-overlay-inner">
|
||||
<MaterialIcon name="upload_file" />
|
||||
<span>Отпустите файл(ы) для добавления</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
onSaveEdit={(content) => {
|
||||
if (editMessage) {
|
||||
panel.handleEditMessage(editMessage.id, content);
|
||||
setEditMessage(null);
|
||||
}
|
||||
}}
|
||||
replyTo={replyTo}
|
||||
replyToVisible={replyToVisible}
|
||||
onClearReply={() => {
|
||||
setPendingAction(null);
|
||||
setReplyToVisible(false);
|
||||
}}
|
||||
onCloseReply={() => {
|
||||
setReplyTo(null);
|
||||
if (pendingAction && pendingAction.type === "edit") {
|
||||
setEditMessage(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
editingMessage={editMessage}
|
||||
editVisible={editVisible}
|
||||
onClearEdit={() => {
|
||||
setPendingAction(null);
|
||||
setEditVisible(false);
|
||||
}}
|
||||
onCloseEdit={() => {
|
||||
setEditMessage(null);
|
||||
if (pendingAction && pendingAction.type === "reply") {
|
||||
setReplyTo(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
|
||||
messagePanelRef={messagePanelRef}
|
||||
onTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
dmPanel.handleTyping();
|
||||
} else {
|
||||
typingManager.sendTyping();
|
||||
}
|
||||
}}
|
||||
onStopTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
typingManager.stopDmTypingOnMessage(dmPanel.getRecipientId()!);
|
||||
} else {
|
||||
typingManager.stopTypingOnMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{panelState?.isLoading ? (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Загрузка сообщений...
|
||||
</div>
|
||||
</div>
|
||||
) : panelState && panel ? (
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
|
||||
onReplySelect={(message) => {
|
||||
if (editMessage || editVisible) {
|
||||
setPendingAction({ type: "reply", message: message });
|
||||
setEditVisible(false); // onCloseEdit will apply pending
|
||||
} else {
|
||||
setReplyTo(message);
|
||||
}
|
||||
}}
|
||||
onEditSelect={(message) => {
|
||||
if (replyTo || replyToVisible) {
|
||||
setPendingAction({ type: "edit", message: message });
|
||||
setReplyToVisible(false); // onCloseReply will apply pending
|
||||
} else {
|
||||
setEditMessage(message);
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => panel.handleDeleteMessage(id)}
|
||||
onRetryMessage={(id) => panel.retryMessage(id)}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
) : (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Выберите чат на боковой панели, чтобы начать переписку
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel && (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isDragging && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="file-overlay"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="file-overlay-wrapper">
|
||||
<div className="file-overlay-inner">
|
||||
<mdui-icon name="upload_file" />
|
||||
<span>Отпустите файл(ы) для добавления</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
onSaveEdit={(content) => {
|
||||
if (editMessage) {
|
||||
panel.handleEditMessage(editMessage.id, content);
|
||||
setEditMessage(null);
|
||||
}
|
||||
}}
|
||||
replyTo={replyTo}
|
||||
replyToVisible={replyToVisible}
|
||||
onClearReply={() => {
|
||||
setPendingAction(null);
|
||||
setReplyToVisible(false);
|
||||
}}
|
||||
onCloseReply={() => {
|
||||
setReplyTo(null);
|
||||
if (pendingAction && pendingAction.type === "edit") {
|
||||
setEditMessage(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
editingMessage={editMessage}
|
||||
editVisible={editVisible}
|
||||
onClearEdit={() => {
|
||||
setPendingAction(null);
|
||||
setEditVisible(false);
|
||||
}}
|
||||
onCloseEdit={() => {
|
||||
setEditMessage(null);
|
||||
if (pendingAction && pendingAction.type === "reply") {
|
||||
setReplyTo(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
|
||||
messagePanelRef={messagePanelRef}
|
||||
onTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
dmPanel.handleTyping();
|
||||
} else {
|
||||
typingManager.sendTyping();
|
||||
}
|
||||
}}
|
||||
onStopTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
typingManager.stopDmTypingOnMessage(dmPanel.getRecipientId()!);
|
||||
} else {
|
||||
typingManager.stopTypingOnMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import useCall from "@/pages/chat/hooks/useCall";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { createPortal } from "react-dom";
|
||||
import { id } from "@/utils/utils";
|
||||
import { MaterialIconButton } from "@/utils/material";
|
||||
|
||||
export function CallWindow() {
|
||||
const { chat, toggleCallMinimize, user } = useAppState();
|
||||
@@ -196,8 +197,8 @@ export function CallWindow() {
|
||||
onMouseDown={(e) => {
|
||||
if (call.isMinimized) {
|
||||
// Only start dragging if not clicking on a button
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("mdui-button-icon")) {
|
||||
// TODO change it to e.stopPropagation() on the buttons
|
||||
if (!e.target.closest("mdui-button-icon")) {
|
||||
setIsDragging(true);
|
||||
setDragOffset({
|
||||
x: e.clientX - pipPosition.x,
|
||||
@@ -209,7 +210,7 @@ export function CallWindow() {
|
||||
>
|
||||
<div className="call-header">
|
||||
<div className="window-controls">
|
||||
<mdui-button-icon
|
||||
<MaterialIconButton
|
||||
onClick={toggleCallMinimize}
|
||||
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
|
||||
className="window-control-btn"
|
||||
@@ -302,15 +303,15 @@ export function CallWindow() {
|
||||
<div className="call-controls">
|
||||
{status === "calling" && !isInitiator ? (
|
||||
<>
|
||||
<mdui-button-icon onClick={acceptCall} icon="call" />
|
||||
<mdui-button-icon onClick={rejectCall} icon="call_end" />
|
||||
<MaterialIconButton onClick={acceptCall} icon="call" />
|
||||
<MaterialIconButton onClick={rejectCall} icon="call_end" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<mdui-button-icon onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} />
|
||||
<mdui-button-icon onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} />
|
||||
<mdui-button-icon onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} />
|
||||
<mdui-button-icon onClick={endCall} icon="call_end" />
|
||||
<MaterialIconButton onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} />
|
||||
<MaterialIconButton onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} />
|
||||
<MaterialIconButton onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} />
|
||||
<MaterialIconButton onClick={endCall} icon="call_end" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import useCall from "@/pages/chat/hooks/useCall";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { MaterialIconButton } from "@/utils/material";
|
||||
|
||||
export function MinimizedCallBar() {
|
||||
const { chat, toggleCallMinimize } = useAppState();
|
||||
@@ -49,11 +50,11 @@ export function MinimizedCallBar() {
|
||||
|
||||
<div className="call-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{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"} />
|
||||
<mdui-button-icon onClick={endCall} icon="call_end" />
|
||||
<MaterialIconButton onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
|
||||
<MaterialIconButton onClick={endCall} icon="call_end" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
import "./download-app.scss";
|
||||
|
||||
export default function DownloadAppPage() {
|
||||
@@ -11,7 +12,7 @@ export default function DownloadAppPage() {
|
||||
</p>
|
||||
|
||||
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
|
||||
<mdui-button>Скачать на GitHub</mdui-button>
|
||||
<MaterialButton>Скачать на GitHub</MaterialButton>
|
||||
</a>
|
||||
|
||||
<p>
|
||||
@@ -19,7 +20,7 @@ export default function DownloadAppPage() {
|
||||
</p>
|
||||
|
||||
<a href="https://t.me/denis0001-dev">
|
||||
<mdui-button>Написать в поддержку</mdui-button>
|
||||
<MaterialButton>Написать в поддержку</MaterialButton>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import "./home.scss";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
||||
|
||||
function GitHubLink({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -32,9 +33,9 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
const openBtn = (
|
||||
<mdui-button variant="filled" onClick={handleGetStarted}>
|
||||
<MaterialButton variant="filled" onClick={handleGetStarted}>
|
||||
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
|
||||
</mdui-button>
|
||||
</MaterialButton>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -48,10 +49,10 @@ export default function HomePage() {
|
||||
</div>
|
||||
<nav className="header-nav">
|
||||
<GitHubLink>
|
||||
<mdui-button variant="text">GitHub</mdui-button>
|
||||
<MaterialButton variant="text">GitHub</MaterialButton>
|
||||
</GitHubLink>
|
||||
<SupportLink>
|
||||
<mdui-button variant="text">Поддержка</mdui-button>
|
||||
<MaterialButton variant="text">Поддержка</MaterialButton>
|
||||
</SupportLink>
|
||||
|
||||
{openBtn}
|
||||
@@ -73,12 +74,13 @@ export default function HomePage() {
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
{openBtn}
|
||||
{!isMobile && <mdui-button
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/register")}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</mdui-button>}
|
||||
{!isMobile && (
|
||||
<MaterialButton
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/register")}>
|
||||
Зарегистрироваться
|
||||
</MaterialButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-visual">
|
||||
@@ -122,7 +124,7 @@ export default function HomePage() {
|
||||
<div className="features-grid">
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<mdui-icon name="security" />
|
||||
<MaterialIcon name="security" />
|
||||
</div>
|
||||
<h4>End-to-End Шифрование</h4>
|
||||
<p>
|
||||
@@ -133,7 +135,7 @@ export default function HomePage() {
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<mdui-icon name="code" />
|
||||
<MaterialIcon name="code" />
|
||||
</div>
|
||||
<h4>100% открытый код</h4>
|
||||
<p>
|
||||
@@ -144,7 +146,7 @@ export default function HomePage() {
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<mdui-icon name="attach_file" />
|
||||
<MaterialIcon name="attach_file" />
|
||||
</div>
|
||||
<h4>Обмен Файлами</h4>
|
||||
<p>
|
||||
@@ -155,7 +157,7 @@ export default function HomePage() {
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<mdui-icon name="notifications" />
|
||||
<MaterialIcon name="notifications" />
|
||||
</div>
|
||||
<h4>Уведомления</h4>
|
||||
<p>
|
||||
@@ -166,7 +168,7 @@ export default function HomePage() {
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<mdui-icon name="edit" />
|
||||
<MaterialIcon name="edit" />
|
||||
</div>
|
||||
<h4>Редактирование</h4>
|
||||
<p>
|
||||
@@ -177,7 +179,7 @@ export default function HomePage() {
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<mdui-icon name="computer" />
|
||||
<MaterialIcon name="computer" />
|
||||
</div>
|
||||
<h4>Кроссплатформенность</h4>
|
||||
<p>
|
||||
@@ -205,20 +207,20 @@ export default function HomePage() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<mdui-button variant="filled">
|
||||
<mdui-icon name="download" slot="icon" />
|
||||
<MaterialButton variant="filled">
|
||||
<MaterialIcon name="download" slot="icon" />
|
||||
Скачать для ПК
|
||||
</mdui-button>
|
||||
</MaterialButton>
|
||||
</a>
|
||||
<mdui-button variant="outlined" onClick={() => navigate("/login")}>
|
||||
<mdui-icon name="language" slot="icon" />
|
||||
<MaterialButton variant="outlined" onClick={() => navigate("/login")}>
|
||||
<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>
|
||||
@@ -234,21 +236,21 @@ export default function HomePage() {
|
||||
</p>
|
||||
<div className="cta-actions">
|
||||
{isMobile ? (
|
||||
<mdui-button variant="filled" onClick={() => navigate("/download-app")}>
|
||||
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
|
||||
Скачать приложение
|
||||
</mdui-button>
|
||||
</MaterialButton>
|
||||
) : (
|
||||
<>
|
||||
<mdui-button
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
onClick={() => navigate("/register")}>
|
||||
Создать аккаунт
|
||||
</mdui-button>
|
||||
<mdui-button
|
||||
</MaterialButton>
|
||||
<MaterialButton
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/login")}>
|
||||
Войти
|
||||
</mdui-button>
|
||||
</MaterialButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./not-found.scss";
|
||||
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -14,22 +15,22 @@ export default function NotFoundPage() {
|
||||
К сожалению, запрашиваемая страница не существует или была перемещена.
|
||||
</p>
|
||||
<div className="not-found-actions">
|
||||
<mdui-button
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
На главную
|
||||
</mdui-button>
|
||||
<mdui-button
|
||||
</MaterialButton>
|
||||
<MaterialButton
|
||||
variant="outlined"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Назад
|
||||
</mdui-button>
|
||||
</MaterialButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="not-found-illustration">
|
||||
<mdui-icon name="search_off"></mdui-icon>
|
||||
<MaterialIcon name="search_off" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
@@ -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">} />
|
||||
}
|
||||
Vendored
+28
-1
@@ -1,2 +1,29 @@
|
||||
/// <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 {};
|
||||
Reference in New Issue
Block a user