mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Redesign settings from scratch
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { MaterialList, MaterialListItem } from "@/utils/material";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { deleteAccount } from "@/core/api/securityApi";
|
||||
import { confirm } from "mdui/functions/confirm";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
interface AccountPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AccountPanel({ onClose }: AccountPanelProps) {
|
||||
const { user, logout } = useAppState();
|
||||
const authToken = user?.authToken;
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (!authToken) return;
|
||||
|
||||
try {
|
||||
await confirm({
|
||||
headline: "Delete Account?",
|
||||
description: "This will permanently delete your account and all your data. This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel"
|
||||
});
|
||||
|
||||
await deleteAccount(authToken);
|
||||
logout();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (error !== "cancelled") {
|
||||
console.error("Failed to delete account:", error);
|
||||
alert(error instanceof Error ? error.message : "Failed to delete account");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Account</h3>
|
||||
<MaterialList>
|
||||
<MaterialListItem
|
||||
onClick={logout}
|
||||
className={styles.clickableItem}
|
||||
headline="Logout"
|
||||
description="Sign out of your account"
|
||||
icon="logout"
|
||||
/>
|
||||
<MaterialListItem
|
||||
onClick={handleDeleteAccount}
|
||||
className={`${styles.clickableItem} ${styles.dangerItem}`}
|
||||
headline="Delete Account"
|
||||
description="Permanently delete your account"
|
||||
icon="delete_forever"
|
||||
/>
|
||||
</MaterialList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useImmer } from "use-immer";
|
||||
import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
|
||||
import { confirm } from "mdui/functions/confirm";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
export function DevicesPanel() {
|
||||
const { user } = useAppState();
|
||||
const authToken = user?.authToken ?? null;
|
||||
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
|
||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||
const [revokingDevices, setRevokingDevices] = useImmer<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (authToken) {
|
||||
loadDevices();
|
||||
}
|
||||
}, [authToken]);
|
||||
|
||||
async function loadDevices() {
|
||||
if (!authToken) return;
|
||||
|
||||
setDevicesLoading(true);
|
||||
try {
|
||||
const deviceList = await listDevices(authToken);
|
||||
updateDevices(deviceList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load devices:", error);
|
||||
} finally {
|
||||
setDevicesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeDevice(sessionId: string) {
|
||||
if (!authToken) return;
|
||||
|
||||
try {
|
||||
await confirm({
|
||||
headline: "Revoke Device?",
|
||||
description: "This will log out this device. You will need to log in again on this device.",
|
||||
confirmText: "Revoke",
|
||||
cancelText: "Cancel"
|
||||
});
|
||||
|
||||
setRevokingDevices(draft => {
|
||||
draft.add(sessionId);
|
||||
});
|
||||
|
||||
await revokeDevice(authToken, sessionId);
|
||||
await loadDevices();
|
||||
} catch (error) {
|
||||
if (error !== "cancelled") {
|
||||
console.error("Failed to revoke device:", error);
|
||||
}
|
||||
} finally {
|
||||
setRevokingDevices(draft => {
|
||||
draft.delete(sessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogoutAll() {
|
||||
if (!authToken) return;
|
||||
|
||||
try {
|
||||
await confirm({
|
||||
headline: "Logout All Other Devices?",
|
||||
description: "This will log you out on all other devices. You will remain logged in on this device.",
|
||||
confirmText: "Logout All",
|
||||
cancelText: "Cancel"
|
||||
});
|
||||
|
||||
await logoutAllOtherDevices(authToken);
|
||||
await loadDevices();
|
||||
} catch (error) {
|
||||
if (error !== "cancelled") {
|
||||
console.error("Failed to logout all devices:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDeviceInfo(device: DeviceInfo): string {
|
||||
const parts: string[] = [];
|
||||
if (device.device_name) parts.push(device.device_name);
|
||||
if (device.os_name) parts.push(device.os_name);
|
||||
if (device.browser_name) parts.push(device.browser_name);
|
||||
return parts.length > 0 ? parts.join(" • ") : device.device_type || "Unknown device";
|
||||
}
|
||||
|
||||
function formatLastSeen(dateStr: string | undefined): string {
|
||||
if (!dateStr) return "Never";
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
if (devicesLoading) {
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Devices</h3>
|
||||
<div className={styles.loadingContainer}>
|
||||
<MaterialCircularProgress />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Devices</h3>
|
||||
<MaterialList>
|
||||
{devices.map((device) => (
|
||||
<MaterialListItem
|
||||
key={device.session_id}
|
||||
className={styles.clickableItem}
|
||||
headline={formatDeviceInfo(device)}
|
||||
description={device.current ? "Current" : "Last seen: " + formatLastSeen(device.last_seen)}
|
||||
icon={device.current ? "smartphone" : "phone_android"}
|
||||
onClick={() => handleRevokeDevice(device.session_id)}
|
||||
disabled={revokingDevices.has(device.session_id)}
|
||||
/>
|
||||
))}
|
||||
</MaterialList>
|
||||
{devices.filter(d => !d.current).length > 0 && (
|
||||
<div className={styles.sectionActions}>
|
||||
<MaterialButton
|
||||
onClick={handleLogoutAll}
|
||||
variant="tonal"
|
||||
>
|
||||
Logout All Other Devices
|
||||
</MaterialButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from "@/utils/material";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
export function NotificationsPanel() {
|
||||
const { user } = useAppState();
|
||||
const authToken = user?.authToken ?? null;
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const switchRef = useRef<MDUISwitch>(null);
|
||||
|
||||
async function checkPushStatus() {
|
||||
if (!isSupported()) {
|
||||
setPushEnabled(false);
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
try {
|
||||
let permission: string;
|
||||
if (isElectron) {
|
||||
permission = await window.electronInterface.notifications.requestPermission();
|
||||
} else {
|
||||
permission = Notification.permission;
|
||||
}
|
||||
console.log("checkPushStatus", permission);
|
||||
setPushEnabled(permission === "granted");
|
||||
} catch (error) {
|
||||
console.error("Failed to check push status:", error);
|
||||
setPushEnabled(false);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePushToggle(enabled: boolean) {
|
||||
console.log("handlePushToggle", enabled);
|
||||
if (!authToken || !isSupported() || loading) return;
|
||||
|
||||
// Optimistic update
|
||||
const previousState = pushEnabled;
|
||||
setPushEnabled(enabled);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
// Initialize push notifications (creates service worker and requests permission)
|
||||
const initResult = await initialize();
|
||||
if (!initResult) {
|
||||
throw new Error("Failed to initialize push notifications");
|
||||
}
|
||||
|
||||
// Subscribe to push notifications (sends subscription to server)
|
||||
// The subscribe() function will handle creating/getting the subscription if needed
|
||||
const subscribeResult = await subscribe(authToken);
|
||||
if (!subscribeResult) {
|
||||
throw new Error("Failed to subscribe to push notifications");
|
||||
}
|
||||
|
||||
// Verify the state after subscription - check permission to ensure it's actually granted
|
||||
await checkPushStatus();
|
||||
} else {
|
||||
// Unsubscribe locally first
|
||||
const unsubscribed = await unsubscribe();
|
||||
if (!unsubscribed) {
|
||||
throw new Error("Failed to unsubscribe locally");
|
||||
}
|
||||
|
||||
// Then unsubscribe from server
|
||||
const response = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(authToken)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to unsubscribe from push notifications");
|
||||
}
|
||||
|
||||
// After unsubscribing, permission is still granted but we're not subscribed
|
||||
// So we keep the state as disabled (false)
|
||||
setPushEnabled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle push notifications:", error);
|
||||
// Revert optimistic update
|
||||
setPushEnabled(previousState);
|
||||
// Re-check actual status to sync with reality
|
||||
await checkPushStatus();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleListItemClick(e: React.MouseEvent) {
|
||||
if (checking || loading || !isSupported() || e.target === switchRef.current) return;
|
||||
handlePushToggle(!pushEnabled);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Notifications</h3>
|
||||
<MaterialList>
|
||||
<MaterialListItem
|
||||
className={styles.clickableItem}
|
||||
headline="Push Notifications"
|
||||
description="Receive notifications for new messages"
|
||||
icon="notifications"
|
||||
onClick={handleListItemClick}>
|
||||
<MaterialSwitch
|
||||
checked={pushEnabled}
|
||||
disabled={!isSupported() || loading || checking}
|
||||
onChange={(e) => handlePushToggle(e.target.checked)}
|
||||
slot="end-icon"
|
||||
ref={switchRef}
|
||||
/>
|
||||
</MaterialListItem>
|
||||
</MaterialList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { MaterialList, MaterialListItem } from "@/utils/material";
|
||||
import ChangePasswordDialog from "./ChangePasswordDialog";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
export function SecurityPanel() {
|
||||
const [cpOpen, setCpOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Security</h3>
|
||||
<MaterialList>
|
||||
<MaterialListItem
|
||||
onClick={() => setCpOpen(true)}
|
||||
className={styles.clickableItem}
|
||||
headline="Change Password"
|
||||
description="Change your account password"
|
||||
icon="password"
|
||||
/>
|
||||
</MaterialList>
|
||||
<ChangePasswordDialog isOpen={cpOpen} onOpenChange={setCpOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,182 +1,92 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { PRODUCT_NAME, API_BASE_URL } from "@/core/config";
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import type { DialogProps } from "@/core/types";
|
||||
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 { getAuthHeaders } from "@/core/api/authApi";
|
||||
import ChangePasswordDialog from "./ChangePasswordDialog";
|
||||
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
|
||||
import { useImmer } from "use-immer";
|
||||
import { MaterialButton, MaterialIconButton, MaterialList, MaterialListItem, MaterialSwitch } from "@/utils/material";
|
||||
import { NotificationsPanel } from "./NotificationsPanel";
|
||||
import { DevicesPanel } from "./DevicesPanel";
|
||||
import { SecurityPanel } from "./SecurityPanel";
|
||||
import { AccountPanel } from "./AccountPanel";
|
||||
import { MaterialList, MaterialListItem, MaterialIconButton } from "@/utils/material";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
interface SettingsSection {
|
||||
title: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const [activePanel, setActivePanel] = useState("notifications-settings");
|
||||
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
|
||||
const [pushSupported, setPushSupported] = useState(false);
|
||||
const user = useAppState(state => state.user);
|
||||
const logout = useAppState(state => state.logout);
|
||||
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
|
||||
const [cpOpen, setCpOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPushSupported(isSupported());
|
||||
// For Electron, we assume notifications are enabled if supported
|
||||
// For web browsers, we check if there's a subscription
|
||||
setPushNotificationsEnabled(isSupported());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activePanel === "devices-settings" && user.authToken) {
|
||||
listDevices(user.authToken)
|
||||
.then(list => updateDevices(() => list))
|
||||
.catch(() => {});
|
||||
const sections: SettingsSection[] = [
|
||||
{
|
||||
title: "Notifications",
|
||||
icon: "notifications",
|
||||
component: <NotificationsPanel />
|
||||
},
|
||||
{
|
||||
title: "Devices",
|
||||
icon: "devices",
|
||||
component: <DevicesPanel />
|
||||
},
|
||||
{
|
||||
title: "Security",
|
||||
icon: "lock",
|
||||
component: <SecurityPanel />
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
icon: "account_circle",
|
||||
component: <AccountPanel onClose={() => onOpenChange(false)} />
|
||||
}
|
||||
}, [activePanel, user.authToken, updateDevices]);
|
||||
|
||||
const handlePanelChange = (panelId: string) => {
|
||||
setActivePanel(panelId);
|
||||
};
|
||||
|
||||
const handlePushNotificationToggle = async (enabled: boolean) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(user.authToken);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
|
||||
setPushNotificationsEnabled(true);
|
||||
}
|
||||
} else {
|
||||
await unsubscribe();
|
||||
|
||||
// For Electron, stop the notification receiver
|
||||
if (isElectron) {
|
||||
stopElectronReceiver();
|
||||
}
|
||||
|
||||
// Call API to unsubscribe on server (for web browsers)
|
||||
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(user.authToken)
|
||||
});
|
||||
setPushNotificationsEnabled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle notifications:", error);
|
||||
}
|
||||
};
|
||||
];
|
||||
|
||||
const [activeSection, setActiveSection] = useState<number>(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className={styles.settingsDialog}>
|
||||
<div className={styles.settingsDialogInner}>
|
||||
<div className={styles.header}>
|
||||
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
|
||||
<div className={styles.title}>Настройки</div>
|
||||
<div className={styles.settingsHeader}>
|
||||
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)} />
|
||||
<h2 className={styles.settingsTitle}>Settings</h2>
|
||||
</div>
|
||||
<div className={styles.settingsMenu}>
|
||||
<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={styles.screen}>
|
||||
<div className={`${styles.settingsPanel} ${activePanel === "notifications-settings" ? styles.active : ""}`}>
|
||||
<h3>Уведомления</h3>
|
||||
{pushSupported && (
|
||||
<MaterialSwitch
|
||||
checked={pushNotificationsEnabled}
|
||||
onInput={(e) => handlePushNotificationToggle(e.target.checked)}>
|
||||
Push уведомления
|
||||
</MaterialSwitch>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`${styles.settingsPanel} ${activePanel === "security-settings" ? styles.active : ""}`}>
|
||||
<h3>Безопасность</h3>
|
||||
<MaterialButton variant="tonal" onClick={() => setCpOpen(true)}>Изменить пароль</MaterialButton>
|
||||
</div>
|
||||
<div className={styles.settingsLayout}>
|
||||
<div className={styles.sidebar}>
|
||||
<MaterialList>
|
||||
{sections.map((section, index) => (
|
||||
<MaterialListItem
|
||||
key={index}
|
||||
onClick={() => setActiveSection(index)}
|
||||
active={activeSection === index}
|
||||
rounded
|
||||
headline={section.title}
|
||||
icon={section.icon}
|
||||
/>
|
||||
))}
|
||||
</MaterialList>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.settingsPanel} ${activePanel === "devices-settings" ? styles.active : ""}`}>
|
||||
<h3>Устройства</h3>
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
|
||||
<MaterialButton variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</MaterialButton>
|
||||
<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 className={styles.contentPanel}>
|
||||
<AnimatePresence mode="wait">
|
||||
{sections.map((section, index) => (
|
||||
activeSection === index && (
|
||||
<motion.div
|
||||
key={index}
|
||||
className={styles.panelContent}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
<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 className={`${styles.settingsPanel} ${activePanel === "about-settings" ? styles.active : ""}`}>
|
||||
<h3>О приложении</h3>
|
||||
<p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p>
|
||||
<p><span className={styles.productName}>{PRODUCT_NAME}</span></p>
|
||||
</div>
|
||||
{section.component}
|
||||
</motion.div>
|
||||
)
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledDialog>
|
||||
<ChangePasswordDialog isOpen={cpOpen} onOpenChange={setCpOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user