diff --git a/README.md b/README.md index df88f8e7..6d89fcb9 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ The following changes are already implemented: * 💬 [Add room messages viewer with filters and jump-to-date](https://github.com/etkecc/synapse-admin/commit/60818ca3604aef61e2b5abc0d92e14771bb53637) * 🏗️ [Add Room hierarchy tab](https://github.com/etkecc/synapse-admin/commit/566b7148b5cb3e2fab55078621ba2cb4203b21a1) * ⚙️ [Add control of admin flags for Matrix Client-Server APIs](https://github.com/etkecc/synapse-admin/commit/b272cc11945d8e6adabb3cfe4904ad0ac063549d) +* 📱 [Optimize UI for mobile](https://github.com/etkecc/synapse-admin/pull/1104) #### exclusive for [etke.cc](https://etke.cc) customers diff --git a/src/auth-callback-error.tsx b/src/auth-callback-error.tsx index 0180ca54..0b4d1476 100644 --- a/src/auth-callback-error.tsx +++ b/src/auth-callback-error.tsx @@ -40,7 +40,7 @@ const AuthCallbackErrorView = ({ message, onBack }: { message: string; onBack: ( )} - + {translate("synapseadmin.auth.welcome", { name: welcomeTo })} @@ -52,7 +52,7 @@ const AuthCallbackErrorView = ({ message, onBack }: { message: string; onBack: ( - diff --git a/src/components/BlockRoomButton.tsx b/src/components/BlockRoomButton.tsx index d13715eb..85f1acf4 100644 --- a/src/components/BlockRoomButton.tsx +++ b/src/components/BlockRoomButton.tsx @@ -10,6 +10,8 @@ import { DialogTitle, TextField, } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { useEffect, useState } from "react"; import { Button, @@ -30,6 +32,8 @@ import { SynapseDataProvider } from "../providers/types"; * Block requires confirmation modal, unblock is direct. */ export const BlockRoomButton = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [blocked, setBlocked] = useState(null); @@ -110,7 +114,7 @@ export const BlockRoomButton = () => { - setOpen(false)} maxWidth="sm" fullWidth> + setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen}> {translate("resources.rooms.action.block.title", { room: roomName })} {translate("resources.rooms.action.block.content")} @@ -138,6 +142,8 @@ export const BlockRoomButton = () => { * Bulk block/unblock buttons for room lists (main room list + joined_rooms). */ export const BlockRoomBulkButton = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const { selectedIds } = useListContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -170,7 +176,7 @@ export const BlockRoomBulkButton = () => { - setOpen(false)} maxWidth="sm" fullWidth> + setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen}> {translate("resources.rooms.action.block.title_bulk", { smart_count: selectedIds.length })} @@ -234,6 +240,8 @@ export const UnblockRoomBulkButton = () => { * Toolbar button above the main room list to block a room by ID. */ export const BlockRoomByIdButton = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const [open, setOpen] = useState(false); const [roomId, setRoomId] = useState(""); const [loading, setLoading] = useState(false); @@ -268,7 +276,7 @@ export const BlockRoomByIdButton = () => { - setOpen(false)} maxWidth="sm" fullWidth> + setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen}> {translate("resources.rooms.action.block.title_by_id")} {translate("resources.rooms.action.block.content")} diff --git a/src/components/DeleteRoomButton.tsx b/src/components/DeleteRoomButton.tsx index 37b691f9..b5f4587b 100644 --- a/src/components/DeleteRoomButton.tsx +++ b/src/components/DeleteRoomButton.tsx @@ -10,6 +10,8 @@ import { DialogContentText, DialogTitle, } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { Button, @@ -37,6 +39,8 @@ interface DeleteRoomButtonProps { const resourceName = "rooms"; const DeleteRoomButton: React.FC = props => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); const [open, setOpen] = useState(false); const [block, setBlock] = useState(false); @@ -163,7 +167,7 @@ const DeleteRoomButton: React.FC = props => { > - + {translate(props.confirmTitle)} {translate(props.confirmContent)} diff --git a/src/components/DeleteUserButton.tsx b/src/components/DeleteUserButton.tsx index 64eee93a..c80b89c5 100644 --- a/src/components/DeleteUserButton.tsx +++ b/src/components/DeleteUserButton.tsx @@ -2,7 +2,7 @@ import ActionCheck from "@mui/icons-material/CheckCircle"; import ActionDelete from "@mui/icons-material/Delete"; import AlertError from "@mui/icons-material/ErrorOutline"; import { - Button, + Button as MuiButton, CircularProgress, Dialog, DialogActions, @@ -10,8 +10,11 @@ import { DialogContentText, DialogTitle, } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { + Button, SimpleForm, BooleanInput, useTranslate, @@ -35,6 +38,8 @@ interface DeleteUserButtonProps { const resourceName = "users"; const DeleteUserButton: React.FC = props => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); const [open, setOpen] = useState(false); const [deleteMedia, setDeleteMedia] = useState(false); @@ -159,22 +164,16 @@ const DeleteUserButton: React.FC = props => { return ( - + {translate(props.confirmTitle)} {translate(props.confirmContent)} @@ -209,10 +208,10 @@ const DeleteUserButton: React.FC = props => { )} - - + diff --git a/src/components/DeviceCreateButton.tsx b/src/components/DeviceCreateButton.tsx index d5d20ea0..47973295 100644 --- a/src/components/DeviceCreateButton.tsx +++ b/src/components/DeviceCreateButton.tsx @@ -5,7 +5,7 @@ import { Button as MuiButton, Dialog, DialogActions, DialogContent, DialogTitle, import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; -import { Button, useNotify, useRecordContext, useRefresh, useTranslate } from "react-admin"; +import { useNotify, useRecordContext, useRefresh, useTranslate } from "react-admin"; import { jsonClient } from "../providers/httpClients"; import { invalidateManyRefCache } from "../providers/resourceMap"; @@ -50,9 +50,15 @@ const DeviceCreateButton = () => { return ( <> - + } + onClick={() => setOpen(true)} + fullWidth={fullScreen} + > + {translate("resources.devices.action.create.label")} + {translate("resources.devices.action.create.title")} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 8a9141fb..b16abf10 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -34,23 +34,26 @@ const Footer = ({ logoSrc = "./images/logo.webp" }: { logoSrc?: string }) => { {" "} Synapse Admin {version} - {" "} - by{" "} - - etke.cc - {" "} - (originally developed by Awesome Technologies Innovationslabor GmbH).{" "} - - #synapse-admin:etke.cc + + {" "} + by{" "} + + etke.cc + {" "} + (originally developed by Awesome Technologies Innovationslabor GmbH).{" "} + + #synapse-admin:etke.cc + + ); }; diff --git a/src/components/LoginAsUserButton.tsx b/src/components/LoginAsUserButton.tsx index 4eec0a96..b5e9139b 100644 --- a/src/components/LoginAsUserButton.tsx +++ b/src/components/LoginAsUserButton.tsx @@ -13,12 +13,16 @@ import { TextField, Typography, } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { Button, useDataProvider, useLocale, useNotify, useRecordContext, useTranslate } from "react-admin"; import { SynapseDataProvider } from "../providers/types"; export const LoginAsUserButton = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [resultOpen, setResultOpen] = useState(false); @@ -74,7 +78,7 @@ export const LoginAsUserButton = () => { - + {translate("resources.users.action.login_as.title")} @@ -110,7 +114,7 @@ export const LoginAsUserButton = () => { - + {translate("resources.users.action.login_as.result_title", { user: record.id })} { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -105,7 +109,7 @@ export const PurgeHistoryButton = () => { - + {translate("resources.rooms.action.purge_history.title", { roomName })} diff --git a/src/components/QuarantineAllMediaButton.tsx b/src/components/QuarantineAllMediaButton.tsx index bffb2ec3..eeeedf5b 100644 --- a/src/components/QuarantineAllMediaButton.tsx +++ b/src/components/QuarantineAllMediaButton.tsx @@ -9,6 +9,8 @@ import { DialogContentText, DialogTitle, } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { Button, useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin"; @@ -19,6 +21,8 @@ import { SynapseDataProvider } from "../providers/types"; * Shows a confirmation dialog before proceeding. */ export const QuarantineRoomMediaButton = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -60,7 +64,7 @@ export const QuarantineRoomMediaButton = () => { - setOpen(false)} maxWidth="sm" fullWidth> + setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen}> {translate("resources.rooms.action.quarantine_all.title", { roomName })} {translate("resources.rooms.action.quarantine_all.content")} @@ -88,6 +92,8 @@ export const QuarantineRoomMediaButton = () => { * Shows a confirmation dialog before proceeding. */ export const QuarantineUserMediaButton = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -129,7 +135,7 @@ export const QuarantineUserMediaButton = () => { - setOpen(false)} maxWidth="sm" fullWidth> + setOpen(false)} maxWidth="sm" fullWidth fullScreen={fullScreen}> {translate("resources.users.action.quarantine_all.title", { userName })} {translate("resources.users.action.quarantine_all.content")} diff --git a/src/components/ResetPasswordButton.tsx b/src/components/ResetPasswordButton.tsx index faa70b9f..4ca9e082 100644 --- a/src/components/ResetPasswordButton.tsx +++ b/src/components/ResetPasswordButton.tsx @@ -12,6 +12,8 @@ import { FormControlLabel, Switch, } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { Button, useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin"; @@ -19,6 +21,8 @@ import { SynapseDataProvider } from "../providers/types"; import { generateRandomPassword } from "../utils/password"; export const ResetPasswordButton = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const record = useRecordContext(); const [open, setOpen] = useState(false); const [password, setPassword] = useState(""); @@ -70,7 +74,7 @@ export const ResetPasswordButton = () => { - + {translate("resources.users.action.reset_password.title")} diff --git a/src/components/ServerNotices.tsx b/src/components/ServerNotices.tsx index b89650db..5e924bf6 100644 --- a/src/components/ServerNotices.tsx +++ b/src/components/ServerNotices.tsx @@ -1,6 +1,8 @@ import IconCancel from "@mui/icons-material/Cancel"; import MessageIcon from "@mui/icons-material/Message"; import { Dialog, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { @@ -22,6 +24,8 @@ import { } from "react-admin"; const ServerNoticeDialog = ({ open, onClose, onSubmit }) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); const ServerNoticeToolbar = (props: ToolbarProps & { pristine?: boolean }) => ( @@ -34,7 +38,7 @@ const ServerNoticeDialog = ({ open, onClose, onSubmit }) => { ); return ( - + {translate("resources.servernotices.action.send")} {translate("resources.servernotices.helper.send")} diff --git a/src/components/UserAccountData.tsx b/src/components/UserAccountData.tsx index 3c49409a..e4d7dbcd 100644 --- a/src/components/UserAccountData.tsx +++ b/src/components/UserAccountData.tsx @@ -47,7 +47,22 @@ const UserAccountData = () => { {translate("resources.users.account_data.global")} - {JSON.stringify(globalAccountData, null, 4)} + + {JSON.stringify(globalAccountData, null, 4)} + @@ -55,7 +70,22 @@ const UserAccountData = () => { {translate("resources.users.account_data.rooms")} - {JSON.stringify(roomsAccountData, null, 4)} + + {JSON.stringify(roomsAccountData, null, 4)} + diff --git a/src/components/UserCounts.tsx b/src/components/UserCounts.tsx index e292aabe..345e3631 100644 --- a/src/components/UserCounts.tsx +++ b/src/components/UserCounts.tsx @@ -44,7 +44,9 @@ const UserInfoChips = () => { : null; return ( - + {createdDate && ( } diff --git a/src/components/etke.cc/BillingPage.tsx b/src/components/etke.cc/BillingPage.tsx index e6023e85..2cef24bf 100644 --- a/src/components/etke.cc/BillingPage.tsx +++ b/src/components/etke.cc/BillingPage.tsx @@ -4,6 +4,9 @@ import PaymentIcon from "@mui/icons-material/Payment"; import { Box, Alert, + Chip, + List, + ListItem, Typography, Link, Table, @@ -15,7 +18,9 @@ import { Paper, Button, Tooltip, + useMediaQuery, } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import { Stack } from "@mui/material"; import IconButton from "@mui/material/IconButton"; import { useState, useEffect } from "react"; @@ -48,6 +53,8 @@ const BillingPage = () => { const notify = useNotify(); const locale = useLocale(); const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [paymentsData, setPaymentsData] = useState([]); const [loading, setLoading] = useState(true); const [maintenance, setMaintenance] = useState(false); @@ -220,6 +227,38 @@ const BillingPage = () => { + ) : isSmall ? ( + + {paymentsData.map(payment => ( + + + + {payment.amount.toFixed(2)} {payment.currency} + + + {new Date(payment.paid_at).toLocaleDateString(locale)} + {" · "} + {translate(`etkecc.billing.enums.type.${payment.is_subscription ? "subscription" : "one_time"}`)} + + + + {downloadInvoiceButton(payment)} + + ))} + ) : ( diff --git a/src/components/etke.cc/ServerCommandsPanel.tsx b/src/components/etke.cc/ServerCommandsPanel.tsx index 3f2d748a..d2adaff8 100644 --- a/src/components/etke.cc/ServerCommandsPanel.tsx +++ b/src/components/etke.cc/ServerCommandsPanel.tsx @@ -11,9 +11,13 @@ import { TextField, Autocomplete, Box, + Button as MuiButton, Link, + Stack, Typography, + useMediaQuery, } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import { useEffect, useState } from "react"; import { Button, Loading, useDataProvider, useCreatePath, useLocale, useStore, useTranslate } from "react-admin"; import { Link as RouterLink } from "react-router-dom"; @@ -28,15 +32,15 @@ import { Icons } from "../../utils/icons"; const renderIcon = (icon: string) => { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const IconComponent = Icons[icon] as React.ComponentType | undefined; - return IconComponent ? ( - - ) : null; + return IconComponent ? : null; }; const ServerCommandsPanel = () => { const { etkeccAdmin } = useAppContext(); const createPath = useCreatePath(); const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const { isLoading, maintenance, serverCommands, setServerCommands } = useServerCommands(); const { units } = useUnits(); const [serverProcess, setServerProcess] = useStore("serverProcess", { @@ -176,82 +180,150 @@ const ServerCommandsPanel = () => { . - -
- - - {translate("etkecc.actions.table.command")} - - - {translate("etkecc.actions.table.description")} - - - - - - {Object.entries(serverCommands).map(([command, { icon, args, description, additionalArgs }]) => ( - - - - {renderIcon(icon)} - {command} - - - - - - + renderInput={params => ( + + )} + /> + )} + {args && command !== "restart" && ( + { + setCommandAdditionalArgs(command, e.target.value); + }} + value={additionalArgs} + fullWidth + /> + )} + } + fullWidth + onClick={() => { + runCommand(command); + }} + disabled={ + commandIsRunning || (args && typeof additionalArgs === "string" && additionalArgs.length === 0) + } + > + {translate("etkecc.actions.buttons.run")} + + + + ))} + + ) : ( + +
+ + + {translate("etkecc.actions.table.command")} + + {translate("etkecc.actions.table.description")} + - ))} - -
-
+ + + {Object.entries(serverCommands).map(([command, { icon, args, description, additionalArgs }]) => ( + + + + {renderIcon(icon)} + {command} + + + + + + + + ))} + + + + )} {commandResult.length > 0 && ( } severity="success"> diff --git a/src/components/etke.cc/ServerStatusPage.tsx b/src/components/etke.cc/ServerStatusPage.tsx index f377fd5d..6c314120 100644 --- a/src/components/etke.cc/ServerStatusPage.tsx +++ b/src/components/etke.cc/ServerStatusPage.tsx @@ -108,12 +108,12 @@ const ServerStatusPage = () => { <> <Stack spacing={3} mt={3}> - <Stack spacing={1} direction="row" alignItems="center"> + <Stack spacing={1} direction={{ xs: "column", sm: "row" }} alignItems={{ xs: "flex-start", sm: "center" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Typography variant="h4">{translate("etkecc.status.status")}:</Typography> <StatusChip isOkay={isOkay} command={command} errorLabel={errorLabel} /> </Box> - <Typography variant="h5" color="primary" fontWeight="medium"> + <Typography variant="h5" color="primary" fontWeight="medium" sx={{ wordBreak: "break-all" }}> {host} </Typography> </Stack> diff --git a/src/components/etke.cc/SupportPage.tsx b/src/components/etke.cc/SupportPage.tsx index 3dfbd79c..25ce733a 100644 --- a/src/components/etke.cc/SupportPage.tsx +++ b/src/components/etke.cc/SupportPage.tsx @@ -9,6 +9,9 @@ import { CircularProgress, FormControlLabel, Link, + List, + ListItemButton, + ListItemText, Paper, Stack, Table, @@ -19,7 +22,9 @@ import { TableRow, TextField, Typography, + useMediaQuery, } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import { useEffect, useState } from "react"; import { Title, useDataProvider, useLocale, useNotify, useTranslate } from "react-admin"; import { useNavigate } from "react-router-dom"; @@ -133,6 +138,8 @@ const SupportPage = () => { const notify = useNotify(); const locale = useLocale(); const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const [requests, setRequests] = useState<SupportRequest[]>([]); const [loading, setLoading] = useState(true); const [failure, setFailure] = useState<string | null>(null); @@ -228,6 +235,40 @@ const SupportPage = () => { <Paper elevation={2} sx={{ p: 2 }}> <Typography>{translate("etkecc.support.no_requests")}</Typography> </Paper> + ) : isSmall ? ( + <List disablePadding> + {requests.map(req => ( + <ListItemButton + key={req.id} + onClick={() => navigate(`/support/${req.id}`)} + component={Paper} + elevation={2} + sx={{ mb: 1, flexDirection: "column", alignItems: "flex-start", gap: 0.5 }} + > + <ListItemText + primary={req.subject || req.id} + secondary={ + req.updated_at + ? `${translate("etkecc.support.fields.updated_at")}: ${new Date(req.updated_at).toLocaleString(locale)}` + : undefined + } + /> + {req.status && ( + <Chip + label={translate(`etkecc.support.status.${req.status}`, { _: req.status })} + size="small" + color={ + req.status === "active" || req.status === "open" + ? "success" + : req.status === "closed" + ? "default" + : "info" + } + /> + )} + </ListItemButton> + ))} + </List> ) : ( <TableContainer component={Paper} elevation={2}> <Table> diff --git a/src/components/etke.cc/SupportRequestPage.tsx b/src/components/etke.cc/SupportRequestPage.tsx index 0cfa71e4..9fa9abc3 100644 --- a/src/components/etke.cc/SupportRequestPage.tsx +++ b/src/components/etke.cc/SupportRequestPage.tsx @@ -62,20 +62,21 @@ const MessageRow = ({ borderLeftColor: !isCustomer ? "primary.main" : undefined, }} > - <Stack direction="row"> + <Stack direction={{ xs: "column", sm: "row" }}> <Box onClick={mxid ? () => navigate(`/users/${encodeURIComponent(mxid)}`) : undefined} sx={{ - width: 150, + width: { xs: "100%", sm: 150 }, flexShrink: 0, p: 1.5, - borderRight: "1px solid", + borderRight: { xs: "none", sm: "1px solid" }, + borderBottom: { xs: "1px solid", sm: "none" }, borderColor: "action.selected", bgcolor: "background.default", display: "flex", - flexDirection: "column", + flexDirection: { xs: "row", sm: "column" }, alignItems: "center", - gap: 0.5, + gap: { xs: 1, sm: 0.5 }, cursor: mxid ? "pointer" : undefined, "&:hover": mxid ? { bgcolor: "action.hover" } : undefined, }} diff --git a/src/components/etke.cc/schedules/components/recurring/RecurringCommandEdit.tsx b/src/components/etke.cc/schedules/components/recurring/RecurringCommandEdit.tsx index 28f74a3b..8731e4b4 100644 --- a/src/components/etke.cc/schedules/components/recurring/RecurringCommandEdit.tsx +++ b/src/components/etke.cc/schedules/components/recurring/RecurringCommandEdit.tsx @@ -171,13 +171,14 @@ const RecurringCommandEdit = () => { return ( <> <Title title={pageTitle} /> - <Box sx={{ mt: 2 }}> + <Box sx={{ mt: 2, maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" }}> <Button label={translate("etkecc.actions.buttons.back")} onClick={() => navigate("/server_actions")} - startIcon={<ArrowBackIcon />} sx={{ mb: 2 }} - /> + > + <ArrowBackIcon /> + </Button> <Card> <CardHeader title={pageTitle} /> @@ -187,7 +188,11 @@ const RecurringCommandEdit = () => { <Alert severity="info"> <Typography variant="body1" sx={{ px: 2 }}> {translate("etkecc.actions.command_details_intro")}{" "} - <Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank"> + <Link + href={`https://etke.cc/help/extras/scheduler/#${command.command}`} + target="_blank" + sx={{ wordBreak: "break-all" }} + > {`etke.cc/help/extras/scheduler/#${command.command}`} </Link> . diff --git a/src/components/etke.cc/schedules/components/recurring/RecurringCommandsList.tsx b/src/components/etke.cc/schedules/components/recurring/RecurringCommandsList.tsx index a0acc904..2f4ae731 100644 --- a/src/components/etke.cc/schedules/components/recurring/RecurringCommandsList.tsx +++ b/src/components/etke.cc/schedules/components/recurring/RecurringCommandsList.tsx @@ -1,5 +1,7 @@ import AddIcon from "@mui/icons-material/Add"; import { Paper } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { Loading, Button, useLocale, useTranslate } from "react-admin"; import { DateField, useRecordContext } from "react-admin"; import { Datagrid } from "react-admin"; @@ -19,8 +21,9 @@ const ListActions = () => { <Button label={translate("etkecc.actions.buttons.create")} onClick={() => navigate("/server_actions/recurring/create")} - startIcon={<AddIcon />} - /> + > + <AddIcon /> + </Button> </TopToolbar> ); }; @@ -69,6 +72,8 @@ const RecurringTimeField = ({ label: _label }: { label?: string }) => { const RecurringCommandsList = () => { const locale = useLocale(); const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const { data, isLoading } = useRecurringCommands(); const listContext = useList({ @@ -98,7 +103,7 @@ const RecurringCommandsList = () => { }} > <TextField source="command" label={translate("etkecc.actions.table.command")} /> - <TextField source="args" label={translate("etkecc.actions.table.arguments")} /> + {!isSmall && <TextField source="args" label={translate("etkecc.actions.table.arguments")} />} <RecurringTimeField label={translate("etkecc.actions.table.time_local")} /> <DateField options={DATE_FORMAT} diff --git a/src/components/etke.cc/schedules/components/recurring/RecurringDeleteButton.tsx b/src/components/etke.cc/schedules/components/recurring/RecurringDeleteButton.tsx index 0ac2f19e..9d6522ee 100644 --- a/src/components/etke.cc/schedules/components/recurring/RecurringDeleteButton.tsx +++ b/src/components/etke.cc/schedules/components/recurring/RecurringDeleteButton.tsx @@ -51,8 +51,9 @@ const RecurringDeleteButton = () => { label={translate("etkecc.actions.buttons.delete")} onClick={handleClick} disabled={isDeleting} - startIcon={<DeleteIcon />} - /> + > + <DeleteIcon /> + </Button> <Confirm isOpen={open} title={translate("etkecc.actions.delete_recurring_title")} diff --git a/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit.tsx b/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit.tsx index 3925979d..fddbaea7 100644 --- a/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit.tsx +++ b/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit.tsx @@ -124,13 +124,14 @@ const ScheduledCommandEdit = () => { return ( <> <Title title={pageTitle} /> - <Box sx={{ mt: 2 }}> + <Box sx={{ mt: 2, maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" }}> <Button label={translate("etkecc.actions.buttons.back")} onClick={() => navigate("/server_actions")} - startIcon={<ArrowBackIcon />} sx={{ mb: 2 }} - /> + > + <ArrowBackIcon /> + </Button> <Card> <CardHeader title={pageTitle} /> @@ -138,7 +139,11 @@ const ScheduledCommandEdit = () => { <EtkeAttribution> <Typography variant="body1" sx={{ px: 2 }}> {translate("etkecc.actions.command_details_intro")}{" "} - <Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank"> + <Link + href={`https://etke.cc/help/extras/scheduler/#${command.command}`} + target="_blank" + sx={{ wordBreak: "break-all" }} + > {`etke.cc/help/extras/scheduler/#${command.command}`} </Link> . diff --git a/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandShow.tsx b/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandShow.tsx index 65762ccc..6b78b216 100644 --- a/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandShow.tsx +++ b/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandShow.tsx @@ -1,5 +1,5 @@ import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import { Alert, Box, Card, CardContent, CardHeader, Typography, Link } from "@mui/material"; +import { Alert, Box, Card, CardContent, CardHeader, TextField as MuiTextField, Typography, Link } from "@mui/material"; import { useState, useEffect } from "react"; import { Loading, @@ -52,13 +52,14 @@ const ScheduledCommandShow = () => { return ( <> <Title title={translate("etkecc.actions.scheduled_details_title")} /> - <Box sx={{ mt: 2 }}> + <Box sx={{ mt: 2, maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" }}> <Button label={translate("etkecc.actions.buttons.back")} onClick={() => navigate("/server_actions")} - startIcon={<ArrowBackIcon />} sx={{ mb: 2 }} - /> + > + <ArrowBackIcon /> + </Button> <RecordContextProvider value={command}> <Card> @@ -69,7 +70,11 @@ const ScheduledCommandShow = () => { <Alert severity="info"> <Typography variant="body1" sx={{ px: 2 }}> {translate("etkecc.actions.command_details_intro")}{" "} - <Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank"> + <Link + href={`https://etke.cc/help/extras/scheduler/#${command.command}`} + target="_blank" + sx={{ wordBreak: "break-all" }} + > {`etke.cc/help/extras/scheduler/#${command.command}`} </Link> . @@ -78,7 +83,14 @@ const ScheduledCommandShow = () => { </EtkeAttribution> )} <SimpleShowLayout> - <TextField source="id" label={translate("etkecc.actions.form.id")} /> + <MuiTextField + value={command.id} + label={translate("etkecc.actions.form.id")} + disabled + fullWidth + variant="filled" + size="small" + /> <TextField source="command" label={translate("etkecc.actions.form.command")} /> {command.args && <TextField source="args" label={translate("etkecc.actions.table.arguments")} />} <BooleanField source="is_recurring" label={translate("etkecc.actions.table.is_recurring")} /> diff --git a/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandsList.tsx b/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandsList.tsx index c66b229a..be2bcd03 100644 --- a/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandsList.tsx +++ b/src/components/etke.cc/schedules/components/scheduled/ScheduledCommandsList.tsx @@ -1,5 +1,7 @@ import AddIcon from "@mui/icons-material/Add"; import { Paper } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { Loading, Button, useLocale, useTranslate } from "react-admin"; import { ResourceContextProvider, useList } from "react-admin"; import { ListContextProvider, TextField } from "react-admin"; @@ -20,7 +22,9 @@ const ListActions = () => { return ( <TopToolbar> - <Button label={translate("etkecc.actions.buttons.create")} onClick={handleCreate} startIcon={<AddIcon />} /> + <Button label={translate("etkecc.actions.buttons.create")} onClick={handleCreate}> + <AddIcon /> + </Button> </TopToolbar> ); }; @@ -28,6 +32,8 @@ const ListActions = () => { const ScheduledCommandsList = () => { const locale = useLocale(); const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const { data, isLoading } = useScheduledCommands(); const listContext = useList({ @@ -61,7 +67,7 @@ const ScheduledCommandsList = () => { }} > <TextField source="command" label={translate("etkecc.actions.table.command")} /> - <TextField source="args" label={translate("etkecc.actions.table.arguments")} /> + {!isSmall && <TextField source="args" label={translate("etkecc.actions.table.arguments")} />} <BooleanField source="is_recurring" label={translate("etkecc.actions.table.is_recurring")} /> <DateField options={DATE_FORMAT} diff --git a/src/components/etke.cc/schedules/components/scheduled/ScheduledDeleteButton.tsx b/src/components/etke.cc/schedules/components/scheduled/ScheduledDeleteButton.tsx index 44a0911e..2ed09d20 100644 --- a/src/components/etke.cc/schedules/components/scheduled/ScheduledDeleteButton.tsx +++ b/src/components/etke.cc/schedules/components/scheduled/ScheduledDeleteButton.tsx @@ -51,8 +51,9 @@ const ScheduledDeleteButton = () => { label={translate("etkecc.actions.buttons.delete")} onClick={handleClick} disabled={isDeleting} - startIcon={<DeleteIcon />} - /> + > + <DeleteIcon /> + </Button> <Confirm isOpen={open} title={translate("etkecc.actions.delete_scheduled_title")} diff --git a/src/components/media.tsx b/src/components/media.tsx index c530f50f..000fcb2b 100644 --- a/src/components/media.tsx +++ b/src/components/media.tsx @@ -1,5 +1,4 @@ import BlockIcon from "@mui/icons-material/Block"; -import IconCancel from "@mui/icons-material/Cancel"; import ClearIcon from "@mui/icons-material/Clear"; import DeleteSweepIcon from "@mui/icons-material/DeleteSweep"; import DownloadIcon from "@mui/icons-material/Download"; @@ -8,8 +7,18 @@ import FileOpenIcon from "@mui/icons-material/FileOpen"; import LockIcon from "@mui/icons-material/Lock"; import LockOpenIcon from "@mui/icons-material/LockOpen"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; -import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material"; +import { + Box, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Button as MuiButton, + Tooltip, +} from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { useMutation } from "@tanstack/react-query"; import { get } from "lodash"; import { useState } from "react"; @@ -21,8 +30,6 @@ import { NumberInput, SaveButton, SimpleForm, - Toolbar, - ToolbarProps, useDataProvider, useNotify, useRecordContext, @@ -35,26 +42,23 @@ import { decodeURLComponent } from "../utils/safety"; import { fetchAuthenticatedMedia } from "../utils/fetchMedia"; const DeleteMediaDialog = ({ open, onClose, onSubmit }) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); - const DeleteMediaToolbar = (props: ToolbarProps) => ( - <Toolbar {...props}> - <SaveButton label="delete_media.action.send" icon={<DeleteSweepIcon />} /> - <Button label="ra.action.cancel" onClick={onClose}> - <IconCancel /> - </Button> - </Toolbar> - ); - return ( - <Dialog open={open} onClose={onClose}> + <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("delete_media.action.send")}</DialogTitle> <DialogContent> <DialogContentText>{translate("delete_media.helper.send")}</DialogContentText> - <SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}> + <SimpleForm toolbar={false} onSubmit={onSubmit}> <DateTimeInput source="before_ts" label="delete_media.fields.before_ts" defaultValue={0} parse={dateParser} /> <NumberInput source="size_gt" label="delete_media.fields.size_gt" defaultValue={0} min={0} step={1024} /> <BooleanInput source="keep_profiles" label="delete_media.fields.keep_profiles" defaultValue={true} /> + <DialogActions sx={{ width: "100%", px: 0 }}> + <MuiButton onClick={onClose}>{translate("ra.action.cancel")}</MuiButton> + <SaveButton label="delete_media.action.send" icon={<DeleteSweepIcon />} /> + </DialogActions> </SimpleForm> </DialogContent> </Dialog> @@ -109,29 +113,26 @@ export const DeleteMediaButton = (props: ButtonProps) => { }; const PurgeRemoteMediaDialog = ({ open, onClose, onSubmit }) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const translate = useTranslate(); - const PurgeRemoteMediaToolbar = (props: ToolbarProps) => ( - <Toolbar {...props}> - <SaveButton label="purge_remote_media.action.send" icon={<DeleteSweepIcon />} /> - <Button label="ra.action.cancel" onClick={onClose}> - <IconCancel /> - </Button> - </Toolbar> - ); - return ( - <Dialog open={open} onClose={onClose}> + <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("purge_remote_media.action.send")}</DialogTitle> <DialogContent> <DialogContentText>{translate("purge_remote_media.helper.send")}</DialogContentText> - <SimpleForm toolbar={<PurgeRemoteMediaToolbar />} onSubmit={onSubmit}> + <SimpleForm toolbar={false} onSubmit={onSubmit}> <DateTimeInput source="before_ts" label="purge_remote_media.fields.before_ts" defaultValue={0} parse={dateParser} /> + <DialogActions sx={{ width: "100%", px: 0 }}> + <MuiButton onClick={onClose}>{translate("ra.action.cancel")}</MuiButton> + <SaveButton label="purge_remote_media.action.send" icon={<DeleteSweepIcon />} /> + </DialogActions> </SimpleForm> </DialogContent> </Dialog> diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index b8f673dd..47d86ce3 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -416,7 +416,7 @@ const LoginPage = () => { onChange={(_, newValue) => setLoginMethod(newValue as LoginMethod)} indicatorColor="primary" textColor="primary" - centered + variant="fullWidth" > <Tab label={translate("synapseadmin.auth.credentials")} value="credentials" /> <Tab label={translate("synapseadmin.auth.access_token")} value="accessToken" /> @@ -483,8 +483,12 @@ const LoginPage = () => { /> </Box> )} - <Typography className="serverVersion">{serverVersion}</Typography> - <Typography className="matrixVersions">{matrixVersions}</Typography> + <Typography className="serverVersion" sx={{ wordBreak: "break-word" }}> + {serverVersion} + </Typography> + <Typography className="matrixVersions" sx={{ wordBreak: "break-word" }}> + {matrixVersions} + </Typography> </> ); }; @@ -504,7 +508,7 @@ const LoginPage = () => { {loading ? ( <CircularProgress size={25} thickness={2} /> ) : ( - <Avatar sx={{ width: "120px", height: "120px" }} src={logoUrl} /> + <Avatar sx={{ width: { xs: "80px", sm: "120px" }, height: { xs: "80px", sm: "120px" } }} src={logoUrl} /> )} </Box> <Box className="hint">{translate("synapseadmin.auth.welcome", { name: welcomeTo })}</Box> @@ -524,27 +528,22 @@ const LoginPage = () => { </Select> <FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer> {loginMethod === "credentials" && ( - <CardActions className="actions"> + <CardActions + className="actions" + sx={{ flexDirection: "column", gap: 1, "& > :not(:first-of-type)": { ml: 0 } }} + > {supportPassAuth && ( - <Button size="small" variant="contained" type="submit" color="primary" disabled={loading} fullWidth> + <Button variant="contained" type="submit" color="primary" disabled={loading} fullWidth> {translate("ra.auth.sign_in")} </Button> )} {ssoBaseUrl !== "" && ( - <Button - size="small" - variant="contained" - color="secondary" - onClick={handleSSO} - disabled={loading} - fullWidth - > + <Button variant="contained" color="secondary" onClick={handleSSO} disabled={loading} fullWidth> {translate("synapseadmin.auth.sso_sign_in")} </Button> )} {(oidcVisible || oidcUrl !== "") && ( <Button - size="small" variant="contained" color="secondary" onClick={handleOIDC} diff --git a/src/resources/destinations.tsx b/src/resources/destinations.tsx index eb70e822..16ba0484 100644 --- a/src/resources/destinations.tsx +++ b/src/resources/destinations.tsx @@ -4,11 +4,12 @@ import ErrorIcon from "@mui/icons-material/Error"; import FolderSharedIcon from "@mui/icons-material/FolderShared"; import EmptyState from "../components/EmptyState"; import ViewListIcon from "@mui/icons-material/ViewList"; +import { Box, useMediaQuery } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import { get } from "lodash"; import { MouseEvent } from "react"; import { Button, - Datagrid, DatagridConfigurable, DateField, List, @@ -21,6 +22,7 @@ import { SearchInput, Show, ShowProps, + SimpleList, Tab, TabbedShowLayout, TextField, @@ -119,6 +121,8 @@ const destinationFieldRender = (record: RaRecord) => { export const DestinationList = (props: ListProps) => { const locale = useLocale(); const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.destinations.name", 2)); return ( <List @@ -129,33 +133,54 @@ export const DestinationList = (props: ListProps) => { perPage={50} empty={<EmptyState />} > - <DatagridConfigurable rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}> - <FunctionField - source="destination" - render={destinationFieldRender} - label="resources.destinations.fields.destination" + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.destination} + </Box> + )} + secondaryText={record => + record.failure_ts ? ( + <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}> + <ErrorIcon fontSize="inherit" color="error" /> + {translate("resources.destinations.fields.failure_ts")}:{" "} + {new Date(record.failure_ts).toLocaleString(locale)} + </Box> + ) : null + } + tertiaryText={record => (record.failure_ts ? <DestinationReconnectButton /> : null)} + rowClick={id => `${id}/show/rooms`} /> - <DateField - source="failure_ts" - showTime - options={DATE_FORMAT} - label="resources.destinations.fields.failure_ts" - locales={locale} - /> - <RetryDateField - source="retry_last_ts" - showTime - options={DATE_FORMAT} - label="resources.destinations.fields.retry_last_ts" - locales={locale} - /> - <TextField source="retry_interval" label="resources.destinations.fields.retry_interval" /> - <TextField - source="last_successful_stream_ordering" - label="resources.destinations.fields.last_successful_stream_ordering" - /> - <DestinationReconnectButton /> - </DatagridConfigurable> + ) : ( + <DatagridConfigurable rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}> + <FunctionField + source="destination" + render={destinationFieldRender} + label="resources.destinations.fields.destination" + /> + <DateField + source="failure_ts" + showTime + options={DATE_FORMAT} + label="resources.destinations.fields.failure_ts" + locales={locale} + /> + <RetryDateField + source="retry_last_ts" + showTime + options={DATE_FORMAT} + label="resources.destinations.fields.retry_last_ts" + locales={locale} + /> + <TextField source="retry_interval" label="resources.destinations.fields.retry_interval" /> + <TextField + source="last_successful_stream_ordering" + label="resources.destinations.fields.last_successful_stream_ordering" + /> + <DestinationReconnectButton /> + </DatagridConfigurable> + )} </List> ); }; @@ -164,8 +189,13 @@ export const DestinationShow = (props: ShowProps) => { const translate = useTranslate(); const locale = useLocale(); return ( - <Show actions={<DestinationShowActions />} title={<DestinationTitle />} {...props}> - <TabbedShowLayout> + <Show + actions={<DestinationShowActions />} + title={<DestinationTitle />} + {...props} + sx={{ "& .RaShow-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" } }} + > + <TabbedShowLayout sx={{ "& .MuiTabs-scroller": { overflowX: "auto !important" } }}> <Tab label="status" icon={<ViewListIcon />}> <TextField source="destination" /> <DateField source="failure_ts" showTime options={DATE_FORMAT} locales={locale} /> @@ -182,8 +212,8 @@ export const DestinationShow = (props: ShowProps) => { pagination={<DestinationPagination />} perPage={50} > - <Datagrid style={{ width: "100%" }} rowClick={id => `/rooms/${id}/show`}> - <TextField source="room_id" label="resources.rooms.fields.room_id" /> + <DatagridConfigurable style={{ width: "100%" }} rowClick={id => `/rooms/${id}/show`}> + <TextField source="room_id" label="resources.rooms.fields.room_id" sx={{ wordBreak: "break-all" }} /> <TextField source="stream_ordering" sortable={false} /> <ReferenceField label="resources.rooms.fields.name" @@ -194,7 +224,7 @@ export const DestinationShow = (props: ShowProps) => { > <TextField source="name" sortable={false} /> </ReferenceField> - </Datagrid> + </DatagridConfigurable> </ReferenceManyField> </Tab> </TabbedShowLayout> diff --git a/src/resources/registration_tokens.tsx b/src/resources/registration_tokens.tsx index 9851ea03..c69fbc69 100644 --- a/src/resources/registration_tokens.tsx +++ b/src/resources/registration_tokens.tsx @@ -1,6 +1,8 @@ import BlockIcon from "@mui/icons-material/Block"; import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber"; import RestoreIcon from "@mui/icons-material/RestoreFromTrash"; +import { Box, useMediaQuery } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import EmptyState from "../components/EmptyState"; import { useState } from "react"; import { @@ -11,6 +13,7 @@ import { DatagridConfigurable, DateField, DateTimeInput, + DeleteButton, Edit, EditProps, List, @@ -23,6 +26,7 @@ import { ResourceProps, SaveButton, SimpleForm, + SimpleList, TextInput, TextField, Toolbar, @@ -43,11 +47,13 @@ const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)]; const validateUsesAllowed = [number()]; const validateLength = [number(), maxValue(64)]; -const registrationTokenFilters = [<BooleanInput source="valid" alwaysOn />]; +const registrationTokenFilters = [<BooleanInput key="valid" source="valid" />]; export const RegistrationTokenList = (props: ListProps) => { const locale = useLocale(); const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const isMAS = useIsMAS(); useDocTitle(translate("resources.registration_tokens.name", { smart_count: 2 })); return ( @@ -59,50 +65,80 @@ export const RegistrationTokenList = (props: ListProps) => { perPage={50} empty={<EmptyState />} > - <DatagridConfigurable rowClick="edit"> - <TextField source="token" sortable={false} label="resources.registration_tokens.fields.token" /> - <NumberField source="uses_allowed" sortable={false} label="resources.registration_tokens.fields.uses_allowed" /> - <NumberField source="pending" sortable={false} label="resources.registration_tokens.fields.pending" /> - <NumberField source="completed" sortable={false} label="resources.registration_tokens.fields.completed" /> - <DateField - source="expiry_time" - showTime - options={DATE_FORMAT} - sortable={false} - label="resources.registration_tokens.fields.expiry_time" - locales={locale} + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.token} + </Box> + )} + secondaryText={record => ( + <> + {translate("resources.registration_tokens.fields.uses_allowed")}: {record.uses_allowed ?? "∞"} + {" · "} + {translate("resources.registration_tokens.fields.completed")}: {record.completed ?? 0} + {record.expiry_time && ( + <> + <br /> + {translate("resources.registration_tokens.fields.expiry_time")}:{" "} + {new Date(record.expiry_time).toLocaleString(locale)} + </> + )} + </> + )} + tertiaryText={() => <DeleteButton redirect={false} />} + linkType="edit" /> - {isMAS && ( - <DateField - source="created_at" - showTime - options={DATE_FORMAT} - sortable={false} - label="resources.registration_tokens.fields.created_at" - locales={locale} - /> - )} - {isMAS && ( - <DateField - source="last_used_at" - showTime - options={DATE_FORMAT} + ) : ( + <DatagridConfigurable rowClick="edit"> + <TextField source="token" sortable={false} label="resources.registration_tokens.fields.token" /> + <NumberField + source="uses_allowed" sortable={false} - label="resources.registration_tokens.fields.last_used_at" - locales={locale} + label="resources.registration_tokens.fields.uses_allowed" /> - )} - {isMAS && ( + <NumberField source="pending" sortable={false} label="resources.registration_tokens.fields.pending" /> + <NumberField source="completed" sortable={false} label="resources.registration_tokens.fields.completed" /> <DateField - source="revoked_at" + source="expiry_time" showTime options={DATE_FORMAT} sortable={false} - label="resources.registration_tokens.fields.revoked_at" + label="resources.registration_tokens.fields.expiry_time" locales={locale} /> - )} - </DatagridConfigurable> + {isMAS && ( + <DateField + source="created_at" + showTime + options={DATE_FORMAT} + sortable={false} + label="resources.registration_tokens.fields.created_at" + locales={locale} + /> + )} + {isMAS && ( + <DateField + source="last_used_at" + showTime + options={DATE_FORMAT} + sortable={false} + label="resources.registration_tokens.fields.last_used_at" + locales={locale} + /> + )} + {isMAS && ( + <DateField + source="revoked_at" + showTime + options={DATE_FORMAT} + sortable={false} + label="resources.registration_tokens.fields.revoked_at" + locales={locale} + /> + )} + </DatagridConfigurable> + )} </List> ); }; @@ -184,9 +220,10 @@ const RevokeTokenButton = () => { }; const RegistrationTokenEditToolbar = () => ( - <Toolbar> + <Toolbar sx={{ justifyContent: "space-between" }}> <SaveButton /> <RevokeTokenButton /> + <DeleteButton redirect="list" /> </Toolbar> ); @@ -196,7 +233,10 @@ export const RegistrationTokenEdit = (props: EditProps) => { useDocTitle(`${translate("ra.action.edit")} ${translate("resources.registration_tokens.name")}`); return ( - <Edit {...props}> + <Edit + {...props} + sx={{ "& .RaEdit-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" } }} + > <SimpleForm toolbar={<RegistrationTokenEditToolbar />}> <TextInput source="token" disabled /> <NumberInput source="pending" disabled /> diff --git a/src/resources/reports.tsx b/src/resources/reports.tsx index d91b5df6..6ebefc94 100644 --- a/src/resources/reports.tsx +++ b/src/resources/reports.tsx @@ -60,7 +60,7 @@ const RoomInfoField = () => { const parts = [record.id as string]; if (record.canonical_alias) parts.push(record.canonical_alias as string); if (record.name) parts.push(record.name as string); - return <span>{parts.join(" ")}</span>; + return <span style={{ wordBreak: "break-all" }}>{parts.join(" ")}</span>; }; const LabeledField = ({ label, children }: { label: string; children: React.ReactNode }) => ( @@ -113,14 +113,14 @@ const ReportBasicTab = () => { <LabeledField label={translate("resources.reports.fields.user_id")}> <ReferenceField source="user_id" reference="users" link="show" label={false}> <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> - <TextField source="id" /> + <TextField source="id" sx={{ wordBreak: "break-all" }} /> </ReferenceField> </LabeledField> <LabeledField label={translate("resources.reports.fields.sender")}> <ReferenceField source="sender" reference="users" link="show" label={false}> <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> - <TextField source="id" /> + <TextField source="id" sx={{ wordBreak: "break-all" }} /> </ReferenceField> </LabeledField> @@ -186,8 +186,13 @@ const EventFields = ({ event }: { event: Record<string, any> }) => ( export const ReportShow = (props: ShowProps) => { return ( - <Show {...props} actions={<ReportShowActions />} title={<ReportTitle />}> - <TabbedShowLayout> + <Show + {...props} + actions={<ReportShowActions />} + title={<ReportTitle />} + sx={{ "& .RaShow-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" } }} + > + <TabbedShowLayout sx={{ "& .MuiTabs-scroller": { overflowX: "auto !important" } }}> <Tab label="synapseadmin.reports.tabs.basic" icon={<ViewListIcon />}> <ReportBasicTab /> </Tab> @@ -216,6 +221,8 @@ const ReportShowActions = () => { }; const EventLookupButton = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const [open, setOpen] = useState(false); const [eventId, setEventId] = useState(""); const [loading, setLoading] = useState(false); @@ -255,7 +262,7 @@ const EventLookupButton = () => { <Button label="resources.reports.action.event_lookup.label" onClick={() => setOpen(true)}> <SearchIcon /> </Button> - <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth> + <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <DialogTitle>{translate("resources.reports.action.event_lookup.title")}</DialogTitle> <DialogContent> <MuiTextField @@ -320,7 +327,11 @@ export const ReportList = (props: ListProps) => { > {isSmall ? ( <SimpleList - primaryText={record => `#${record.id} ${record.name || record.room_id}`} + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + #{record.id} {record.name || record.room_id} + </Box> + )} secondaryText={record => { const date = new Date(record.received_ts).toLocaleDateString(locale, DATE_FORMAT); const score = @@ -329,7 +340,11 @@ export const ReportList = (props: ListProps) => { }} tertiaryText={record => { if (!record.user_id) return ""; - return record.user_id; + return ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.user_id} + </Box> + ); }} linkType="show" /> diff --git a/src/resources/room_directory.tsx b/src/resources/room_directory.tsx index 816281d3..1e232225 100644 --- a/src/resources/room_directory.tsx +++ b/src/resources/room_directory.tsx @@ -1,4 +1,6 @@ import RoomDirectoryIcon from "@mui/icons-material/FolderShared"; +import { Box, useMediaQuery } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import EmptyState from "../components/EmptyState"; import { useMutation } from "@tanstack/react-query"; import { @@ -16,6 +18,7 @@ import { Pagination, ResourceProps, SelectColumnsButton, + SimpleList, TextField, TopToolbar, useCreate, @@ -132,15 +135,21 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => { ); }; -const RoomDirectoryListActions = () => ( - <TopToolbar> - <SelectColumnsButton /> - <ExportButton /> - </TopToolbar> -); +const RoomDirectoryListActions = () => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); + return ( + <TopToolbar> + {!isSmall && <SelectColumnsButton />} + <ExportButton /> + </TopToolbar> + ); +}; export const RoomDirectoryList = () => { const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.room_directory.name", { smart_count: 2 })); return ( <List @@ -149,21 +158,70 @@ export const RoomDirectoryList = () => { actions={<RoomDirectoryListActions />} empty={<EmptyState />} > - <DatagridConfigurable - rowClick={id => "/rooms/" + id + "/show"} - bulkActionButtons={<RoomDirectoryBulkUnpublishButton />} - omit={["room_id", "canonical_alias", "topic"]} - > - <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} label="resources.rooms.fields.avatar" /> - <TextField source="name" sortable={false} label="resources.rooms.fields.name" /> - <TextField source="room_id" sortable={false} label="resources.rooms.fields.room_id" /> - <TextField source="canonical_alias" sortable={false} label="resources.rooms.fields.canonical_alias" /> - <TextField source="topic" sortable={false} label="resources.rooms.fields.topic" /> - <NumberField source="num_joined_members" sortable={false} label="resources.rooms.fields.joined_members" /> - <BooleanField source="world_readable" sortable={false} label="resources.room_directory.fields.world_readable" /> - <BooleanField source="guest_can_join" sortable={false} label="resources.room_directory.fields.guest_can_join" /> - <MakeAdminBtn /> - </DatagridConfigurable> + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.name || record.canonical_alias || record.room_id} + </Box> + )} + secondaryText={record => ( + <> + {record.canonical_alias && ( + <> + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.canonical_alias} + </Box> + <br /> + </> + )} + {translate("resources.rooms.fields.joined_members")}: {record.num_joined_members ?? 0} + </> + )} + linkType="show" + leftAvatar={record => ( + <AvatarField record={record} source="avatar_src" sx={{ height: "40px", width: "40px" }} /> + )} + /> + ) : ( + <DatagridConfigurable + rowClick={id => "/rooms/" + id + "/show"} + bulkActionButtons={<RoomDirectoryBulkUnpublishButton />} + omit={["room_id", "canonical_alias", "topic"]} + > + <AvatarField + source="avatar_src" + sx={{ height: "40px", width: "40px" }} + label="resources.rooms.fields.avatar" + /> + <TextField source="name" sortable={false} label="resources.rooms.fields.name" /> + <TextField + source="room_id" + sortable={false} + label="resources.rooms.fields.room_id" + sx={{ wordBreak: "break-all" }} + /> + <TextField + source="canonical_alias" + sortable={false} + label="resources.rooms.fields.canonical_alias" + sx={{ wordBreak: "break-all" }} + /> + <TextField source="topic" sortable={false} label="resources.rooms.fields.topic" /> + <NumberField source="num_joined_members" sortable={false} label="resources.rooms.fields.joined_members" /> + <BooleanField + source="world_readable" + sortable={false} + label="resources.room_directory.fields.world_readable" + /> + <BooleanField + source="guest_can_join" + sortable={false} + label="resources.room_directory.fields.guest_can_join" + /> + <MakeAdminBtn /> + </DatagridConfigurable> + )} </List> ); }; diff --git a/src/resources/rooms.tsx b/src/resources/rooms.tsx index 5b0e8b86..d837416b 100644 --- a/src/resources/rooms.tsx +++ b/src/resources/rooms.tsx @@ -19,7 +19,10 @@ import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Chip from "@mui/material/Chip"; import Divider from "@mui/material/Divider"; +import MuiList from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -29,9 +32,9 @@ import { BooleanField, DateField, WrapperField, - Datagrid, DatagridConfigurable, ExportButton, + FilterButton, FunctionField, List, ListProps, @@ -42,6 +45,7 @@ import { ResourceProps, SearchInput, SelectColumnsButton, + SimpleList, Show, ShowProps, Tab, @@ -56,7 +60,9 @@ import { DeleteButton, NullableBooleanInput, useLocale, + useGetMany, } from "react-admin"; +import { Link } from "react-router-dom"; import { useDataProvider } from "react-admin"; import { Confirm } from "react-admin"; @@ -118,7 +124,7 @@ const RoomShowActions = () => { const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />; // FIXME: refresh after (un)publish return ( - <TopToolbar> + <TopToolbar sx={{ flexWrap: "wrap", gap: 0.5, whiteSpace: "normal" }}> {publishButton} <BlockRoomButton /> <PurgeHistoryButton /> @@ -353,7 +359,7 @@ const RoomOverviewTab = () => { {record.room_id} </Typography> {record.canonical_alias && ( - <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}> + <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5, wordBreak: "break-all" }}> {record.canonical_alias} </Typography> )} @@ -395,7 +401,7 @@ const RoomOverviewTab = () => { <Typography variant="subtitle2" color="text.secondary" gutterBottom> {translate("synapseadmin.rooms.tabs.detail")} </Typography> - <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}> + <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 1 }}> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.joined_members")} @@ -429,7 +435,7 @@ const RoomOverviewTab = () => { <Typography variant="subtitle2" color="text.secondary" gutterBottom> {translate("synapseadmin.rooms.tabs.permission")} </Typography> - <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}> + <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 1 }}> <Box> <Typography variant="caption" color="text.secondary"> {translate("resources.rooms.fields.join_rules")} @@ -461,7 +467,7 @@ const RoomOverviewTab = () => { {translate("resources.rooms.fields.creator")} </Typography> <ReferenceField source="creator" reference="users" link="show"> - <RaTextField source="id" /> + <RaTextField source="id" sx={{ wordBreak: "break-all" }} /> </ReferenceField> </Box> </Box> @@ -472,12 +478,56 @@ const RoomOverviewTab = () => { ); }; +const RoomMembersMobileList = () => { + const { data: members } = useListContext(); + const ids = (members || []).map(r => r.id); + const { data: users } = useGetMany("users", { ids }, { enabled: ids.length > 0 }); + const userMap = new Map((users || []).map(u => [u.id, u])); + + if (!members?.length) return null; + + return ( + <MuiList disablePadding> + {members.map(record => { + const user = userMap.get(record.id); + return ( + <ListItemButton + key={record.id as string} + component={Link} + to={"/users/" + record.id} + sx={{ gap: 1, alignItems: "center" }} + > + <AvatarField record={user || record} source="avatar_src" sx={{ height: "40px", width: "40px" }} /> + <Box sx={{ flex: 1, minWidth: 0 }}> + <Typography variant="body1" sx={{ wordBreak: "break-all" }}> + {user?.displayname || record.id} + </Typography> + {user?.displayname && ( + <Typography variant="body2" color="text.secondary" sx={{ wordBreak: "break-all" }}> + {record.id} + </Typography> + )} + </Box> + </ListItemButton> + ); + })} + </MuiList> + ); +}; + export const RoomShow = (props: ShowProps) => { const translate = useTranslate(); const locale = useLocale(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); return ( - <Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}> - <TabbedShowLayout> + <Show + {...props} + actions={<RoomShowActions />} + title={<RoomTitle />} + sx={{ "& .RaShow-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" } }} + > + <TabbedShowLayout sx={{ "& .MuiTabs-scroller": { overflowX: "auto !important" } }}> <Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}> <RoomOverviewTab /> </Tab> @@ -491,63 +541,72 @@ export const RoomShow = (props: ShowProps) => { perPage={10} pagination={<RoomPagination />} > - <Datagrid sx={{ width: "100%" }} rowClick={id => "/users/" + id} bulkActionButtons={false}> - <ReferenceField - label="resources.users.fields.avatar" - source="id" - reference="users" - sortable={false} - link="" - > - <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> - </ReferenceField> - <RaTextField source="id" sortable={false} label="resources.users.fields.id" /> - <ReferenceField - label="resources.users.fields.displayname" - source="id" - reference="users" - sortable={false} - link="" - > - <RaTextField source="displayname" sortable={false} /> - </ReferenceField> - <ReferenceField - label="resources.users.fields.is_guest" - source="id" - reference="users" - sortable={false} - link="" - > - <BooleanField source="is_guest" label="resources.users.fields.is_guest" /> - </ReferenceField> - <ReferenceField - label="resources.users.fields.deactivated" - source="id" - reference="users" - sortable={false} - link="" - > - <BooleanField source="deactivated" label="resources.users.fields.deactivated" /> - </ReferenceField> - <ReferenceField - label="resources.users.fields.locked" - source="id" - reference="users" - sortable={false} - link="" - > - <BooleanField source="locked" label="resources.users.fields.locked" /> - </ReferenceField> - <ReferenceField - label="resources.users.fields.erased" - source="id" - reference="users" - sortable={false} - link="" - > - <BooleanField source="erased" sortable={false} label="resources.users.fields.erased" /> - </ReferenceField> - </Datagrid> + {isSmall ? ( + <RoomMembersMobileList /> + ) : ( + <DatagridConfigurable sx={{ width: "100%" }} rowClick={id => "/users/" + id} bulkActionButtons={false}> + <ReferenceField + label="resources.users.fields.avatar" + source="id" + reference="users" + sortable={false} + link="" + > + <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> + </ReferenceField> + <RaTextField + source="id" + sortable={false} + label="resources.users.fields.id" + sx={{ wordBreak: "break-all" }} + /> + <ReferenceField + label="resources.users.fields.displayname" + source="id" + reference="users" + sortable={false} + link="" + > + <RaTextField source="displayname" sortable={false} /> + </ReferenceField> + <ReferenceField + label="resources.users.fields.is_guest" + source="id" + reference="users" + sortable={false} + link="" + > + <BooleanField source="is_guest" label="resources.users.fields.is_guest" /> + </ReferenceField> + <ReferenceField + label="resources.users.fields.deactivated" + source="id" + reference="users" + sortable={false} + link="" + > + <BooleanField source="deactivated" label="resources.users.fields.deactivated" /> + </ReferenceField> + <ReferenceField + label="resources.users.fields.locked" + source="id" + reference="users" + sortable={false} + link="" + > + <BooleanField source="locked" label="resources.users.fields.locked" /> + </ReferenceField> + <ReferenceField + label="resources.users.fields.erased" + source="id" + reference="users" + sortable={false} + link="" + > + <BooleanField source="erased" sortable={false} label="resources.users.fields.erased" /> + </ReferenceField> + </DatagridConfigurable> + )} </ReferenceManyField> </Tab> @@ -561,10 +620,22 @@ export const RoomShow = (props: ShowProps) => { pagination={<Pagination />} perPage={10} > - <Datagrid sx={{ width: "100%" }} bulkActionButtons={false}> - <MediaIDField source="media_id" /> - <DeleteButton mutationMode="pessimistic" redirect={false} /> - </Datagrid> + {isSmall ? ( + <SimpleList + primaryText={() => ( + <Box sx={{ wordBreak: "break-all" }}> + <MediaIDField source="media_id" /> + </Box> + )} + tertiaryText={() => <DeleteButton mutationMode="pessimistic" redirect={false} />} + linkType={false} + /> + ) : ( + <DatagridConfigurable sx={{ width: "100%" }} bulkActionButtons={false}> + <MediaIDField source="media_id" /> + <DeleteButton mutationMode="pessimistic" redirect={false} /> + </DatagridConfigurable> + )} </ReferenceManyField> </Tab> @@ -576,18 +647,55 @@ export const RoomShow = (props: ShowProps) => { pagination={<Pagination />} perPage={10} > - <Datagrid sx={{ width: "100%" }} bulkActionButtons={false}> - <RaTextField source="type" sortable={false} /> - <DateField source="origin_server_ts" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> - <FunctionField - source="content" - sortable={false} - render={record => `${JSON.stringify(record.content, null, 2)}`} + {isSmall ? ( + <SimpleList + primaryText={record => record.type} + secondaryText={record => ( + <> + {record.origin_server_ts && new Date(record.origin_server_ts).toLocaleString(locale)} + {record.sender && ( + <> + <br /> + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.sender} + </Box> + </> + )} + <Box + component="pre" + sx={{ + whiteSpace: "pre-wrap", + wordBreak: "break-all", + m: 0, + mt: 0.5, + p: 1, + fontSize: "0.75rem", + bgcolor: "action.hover", + borderRadius: 1, + overflow: "auto", + maxWidth: "100%", + }} + > + {JSON.stringify(record.content, null, 2)} + </Box> + </> + )} + linkType={false} /> - <ReferenceField source="sender" reference="users" sortable={false}> - <RaTextField source="id" /> - </ReferenceField> - </Datagrid> + ) : ( + <DatagridConfigurable sx={{ width: "100%" }} bulkActionButtons={false}> + <RaTextField source="type" sortable={false} /> + <DateField source="origin_server_ts" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> + <FunctionField + source="content" + sortable={false} + render={record => `${JSON.stringify(record.content, null, 2)}`} + /> + <ReferenceField source="sender" reference="users" sortable={false}> + <RaTextField source="id" sx={{ wordBreak: "break-all" }} /> + </ReferenceField> + </DatagridConfigurable> + )} </ReferenceManyField> </Tab> @@ -615,12 +723,34 @@ export const RoomShow = (props: ShowProps) => { pagination={<Pagination />} perPage={10} > - <Datagrid sx={{ width: "100%" }} bulkActionButtons={false}> - <RaTextField source="id" sortable={false} /> - <DateField source="received_ts" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> - <NumberField source="depth" sortable={false} /> - <RaTextField source="state_group" sortable={false} /> - </Datagrid> + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.id} + </Box> + )} + secondaryText={record => ( + <> + {record.received_ts && new Date(record.received_ts).toLocaleString(locale)} + {record.state_group && ( + <> + {" "} + · {translate("resources.forward_extremities.fields.state_group")}: {record.state_group} + </> + )} + </> + )} + linkType={false} + /> + ) : ( + <DatagridConfigurable sx={{ width: "100%" }} bulkActionButtons={false} omit={["depth", "received_ts"]}> + <RaTextField source="id" sortable={false} sx={{ wordBreak: "break-all" }} /> + <DateField source="received_ts" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> + <NumberField source="depth" sortable={false} /> + <RaTextField source="state_group" sortable={false} /> + </DatagridConfigurable> + )} </ReferenceManyField> </Tab> </TabbedShowLayout> @@ -646,22 +776,28 @@ export const RoomBulkActionButtons = () => { }; const roomFilters = [ - <SearchInput source="search_term" alwaysOn />, - <NullableBooleanInput source="public_rooms" label="resources.rooms.filter.public_rooms" alwaysOn />, - <NullableBooleanInput source="empty_rooms" label="resources.rooms.filter.empty_rooms" alwaysOn />, + <SearchInput key="search_term" source="search_term" alwaysOn />, + <NullableBooleanInput key="public_rooms" source="public_rooms" label="resources.rooms.filter.public_rooms" />, + <NullableBooleanInput key="empty_rooms" source="empty_rooms" label="resources.rooms.filter.empty_rooms" />, ]; -const RoomListActions = () => ( - <TopToolbar> - <BlockRoomByIdButton /> - <SelectColumnsButton /> - <ExportButton /> - </TopToolbar> -); +const RoomListActions = () => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); + return ( + <TopToolbar> + <FilterButton /> + <BlockRoomByIdButton /> + {!isSmall && <SelectColumnsButton />} + <ExportButton /> + </TopToolbar> + ); +}; export const RoomList = (props: ListProps) => { const theme = useTheme(); const translate = useTranslate(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.rooms.name", { smart_count: 2 })); return ( @@ -674,57 +810,96 @@ export const RoomList = (props: ListProps) => { perPage={50} empty={<EmptyState />} > - <DatagridConfigurable - rowClick="show" - bulkActionButtons={<RoomBulkActionButtons />} - omit={["joined_local_members", "state_events", "version", "federatable", "join_rules"]} - > - <ReferenceField - reference="rooms" - source="id" - label="resources.users.fields.avatar" - link={false} - sortable={false} + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.name || record.canonical_alias || record.id} + </Box> + )} + secondaryText={record => ( + <> + {translate("resources.rooms.fields.joined_members")}: {record.joined_members ?? 0} + {record.creator && ( + <> + <br /> + <Box component="span" sx={{ wordBreak: "break-all" }}> + {translate("resources.rooms.fields.creator")}: {record.creator} + </Box> + </> + )} + </> + )} + tertiaryText={record => ( + <Box sx={{ display: "flex", gap: 0.5 }}> + {record.is_encrypted ? ( + <Tooltip title={translate("resources.rooms.fields.encryption")}> + <HttpsIcon fontSize="small" sx={{ color: theme.palette.success.main }} /> + </Tooltip> + ) : ( + <Tooltip title={translate("resources.rooms.fields.encryption")}> + <NoEncryptionIcon fontSize="small" sx={{ color: theme.palette.error.main }} /> + </Tooltip> + )} + </Box> + )} + linkType="show" + leftAvatar={record => ( + <AvatarField record={record} source="avatar_src" sx={{ height: "40px", width: "40px" }} /> + )} + /> + ) : ( + <DatagridConfigurable + rowClick="show" + bulkActionButtons={<RoomBulkActionButtons />} + omit={["joined_local_members", "state_events", "version", "federatable", "join_rules"]} > - <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> - </ReferenceField> - <RaTextField source="id" label="resources.rooms.fields.room_id" sortable={false} /> - <WrapperField source="encryption" sortBy="encryption" label="resources.rooms.fields.encryption"> - <BooleanField - source="is_encrypted" - sortBy="encryption" - TrueIcon={HttpsIcon} - FalseIcon={NoEncryptionIcon} - label={<HttpsIcon />} + <ReferenceField + reference="rooms" + source="id" + label="resources.users.fields.avatar" + link={false} + sortable={false} + > + <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> + </ReferenceField> + <RaTextField source="id" label="resources.rooms.fields.room_id" sortable={false} /> + <WrapperField source="encryption" sortBy="encryption" label="resources.rooms.fields.encryption"> + <BooleanField + source="is_encrypted" + sortBy="encryption" + TrueIcon={HttpsIcon} + FalseIcon={NoEncryptionIcon} + label={<HttpsIcon />} + sx={{ + [`& [data-testid="true"]`]: { color: theme.palette.success.main }, + [`& [data-testid="false"]`]: { color: theme.palette.error.main }, + }} + /> + </WrapperField> + <FunctionField + source="name" sx={{ - [`& [data-testid="true"]`]: { color: theme.palette.success.main }, - [`& [data-testid="false"]`]: { color: theme.palette.error.main }, + wordBreak: "break-all", }} + render={record => record["name"] || record["canonical_alias"] || record["id"]} + label="resources.rooms.fields.name" /> - </WrapperField> - <FunctionField - source="name" - sx={{ - wordBreak: "break-word", - overflowWrap: "break-word", - }} - render={record => record["name"] || record["canonical_alias"] || record["id"]} - label="resources.rooms.fields.name" - /> - <RaTextField source="joined_members" label="resources.rooms.fields.joined_members" /> - <RaTextField source="joined_local_members" label="resources.rooms.fields.joined_local_members" /> - <RaTextField source="state_events" label="resources.rooms.fields.state_events" /> - <RaTextField source="version" label="resources.rooms.fields.version" /> - <RaTextField source="join_rules" label="resources.rooms.fields.join_rules" /> - <ReferenceField source="creator" reference="users"> - <RaTextField source="id" label="resources.rooms.fields.creator" /> - </ReferenceField> - <BooleanField source="federatable" label="resources.rooms.fields.federatable" /> - <BooleanField source="public" label="resources.rooms.fields.public" /> - <WrapperField label="resources.rooms.fields.actions"> - <MakeAdminBtn /> - </WrapperField> - </DatagridConfigurable> + <RaTextField source="joined_members" label="resources.rooms.fields.joined_members" /> + <RaTextField source="joined_local_members" label="resources.rooms.fields.joined_local_members" /> + <RaTextField source="state_events" label="resources.rooms.fields.state_events" /> + <RaTextField source="version" label="resources.rooms.fields.version" /> + <RaTextField source="join_rules" label="resources.rooms.fields.join_rules" /> + <ReferenceField source="creator" reference="users"> + <RaTextField source="id" label="resources.rooms.fields.creator" sx={{ wordBreak: "break-all" }} /> + </ReferenceField> + <BooleanField source="federatable" label="resources.rooms.fields.federatable" /> + <BooleanField source="public" label="resources.rooms.fields.public" /> + <WrapperField label="resources.rooms.fields.actions"> + <MakeAdminBtn /> + </WrapperField> + </DatagridConfigurable> + )} </List> ); }; diff --git a/src/resources/scheduled_tasks.tsx b/src/resources/scheduled_tasks.tsx index 9fb47cc6..11068353 100644 --- a/src/resources/scheduled_tasks.tsx +++ b/src/resources/scheduled_tasks.tsx @@ -1,5 +1,5 @@ import ScheduleIcon from "@mui/icons-material/Schedule"; -import { Chip } from "@mui/material"; +import { Box, Chip } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { @@ -49,7 +49,6 @@ const scheduledTaskFilters = (translate: ReturnType<typeof useTranslate>) => [ <SelectInput key="status" source="status" - alwaysOn choices={["scheduled", "active", "complete", "cancelled", "failed"].map(s => ({ id: s, name: translate(`resources.scheduled_tasks.status.${s}`), @@ -90,7 +89,15 @@ export const ScheduledTaskList = (props: ListProps) => { </> )} secondaryText={record => new Date(record.timestamp_ms).toLocaleDateString(locale, DATE_FORMAT)} - tertiaryText={record => record.resource_id || ""} + tertiaryText={record => + record.resource_id ? ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.resource_id} + </Box> + ) : ( + "" + ) + } linkType={false} /> ) : ( @@ -111,7 +118,12 @@ export const ScheduledTaskList = (props: ListProps) => { label="resources.scheduled_tasks.fields.timestamp" locales={locale} /> - <TextField source="resource_id" sortable={false} label="resources.scheduled_tasks.fields.resource_id" /> + <TextField + source="resource_id" + sortable={false} + label="resources.scheduled_tasks.fields.resource_id" + sx={{ wordBreak: "break-all" }} + /> <FunctionField source="result" sortable={false} diff --git a/src/resources/user_media_statistics.tsx b/src/resources/user_media_statistics.tsx index 2961e82f..0e21e721 100644 --- a/src/resources/user_media_statistics.tsx +++ b/src/resources/user_media_statistics.tsx @@ -1,9 +1,12 @@ import PermMediaIcon from "@mui/icons-material/PermMedia"; +import { Box, useMediaQuery } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import EmptyState from "../components/EmptyState"; import { BooleanField, DatagridConfigurable, ExportButton, + FunctionField, List, ListProps, NumberField, @@ -11,6 +14,7 @@ import { ReferenceField, ResourceProps, SearchInput, + SimpleList, TextField, TopToolbar, useListContext, @@ -19,6 +23,7 @@ import { import AvatarField from "../components/AvatarField"; import { useDocTitle } from "../components/hooks/useDocTitle"; +import { formatBytes } from "../utils/formatBytes"; import { DeleteMediaButton, PurgeRemoteMediaButton } from "../components/media"; const ListActions = () => { @@ -38,6 +43,8 @@ const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />]; export const UserMediaStatsList = (props: ListProps) => { const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.user_media_statistics.name", { smart_count: 2 })); return ( <List @@ -49,33 +56,65 @@ export const UserMediaStatsList = (props: ListProps) => { perPage={50} empty={<EmptyState />} > - <DatagridConfigurable rowClick={id => "/users/" + id + "/media"} bulkActionButtons={false}> - <ReferenceField label="resources.users.fields.avatar" source="id" reference="users" sortable={false} link=""> - <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> - </ReferenceField> - <TextField source="user_id" label="resources.users.fields.id" /> - <TextField source="displayname" label="resources.users.fields.displayname" /> - <NumberField source="media_count" /> - <NumberField source="media_length" /> - <ReferenceField label="resources.users.fields.is_guest" source="id" reference="users" sortable={false} link=""> - <BooleanField source="is_guest" label="resources.users.fields.is_guest" /> - </ReferenceField> - <ReferenceField - label="resources.users.fields.deactivated" - source="id" - reference="users" - sortable={false} - link="" - > - <BooleanField source="deactivated" label="resources.users.fields.deactivated" /> - </ReferenceField> - <ReferenceField label="resources.users.fields.locked" source="id" reference="users" sortable={false} link=""> - <BooleanField source="locked" label="resources.users.fields.locked" /> - </ReferenceField> - <ReferenceField label="resources.users.fields.erased" source="id" reference="users" sortable={false} link=""> - <BooleanField source="erased" sortable={false} label="resources.users.fields.erased" /> - </ReferenceField> - </DatagridConfigurable> + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.displayname || record.user_id} + </Box> + )} + secondaryText={record => ( + <> + {record.displayname && ( + <> + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.user_id} + </Box> + <br /> + </> + )} + {translate("resources.user_media_statistics.fields.media_count")}: {record.media_count} + {" · "} + {translate("resources.user_media_statistics.fields.media_length")}: {formatBytes(record.media_length)} + </> + )} + rowClick={id => "/users/" + id + "/media"} + /> + ) : ( + <DatagridConfigurable rowClick={id => "/users/" + id + "/media"} bulkActionButtons={false}> + <ReferenceField label="resources.users.fields.avatar" source="id" reference="users" sortable={false} link=""> + <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} /> + </ReferenceField> + <TextField source="user_id" label="resources.users.fields.id" sx={{ wordBreak: "break-all" }} /> + <TextField source="displayname" label="resources.users.fields.displayname" /> + <NumberField source="media_count" /> + <FunctionField source="media_length" render={record => formatBytes(record.media_length)} /> + <ReferenceField + label="resources.users.fields.is_guest" + source="id" + reference="users" + sortable={false} + link="" + > + <BooleanField source="is_guest" label="resources.users.fields.is_guest" /> + </ReferenceField> + <ReferenceField + label="resources.users.fields.deactivated" + source="id" + reference="users" + sortable={false} + link="" + > + <BooleanField source="deactivated" label="resources.users.fields.deactivated" /> + </ReferenceField> + <ReferenceField label="resources.users.fields.locked" source="id" reference="users" sortable={false} link=""> + <BooleanField source="locked" label="resources.users.fields.locked" /> + </ReferenceField> + <ReferenceField label="resources.users.fields.erased" source="id" reference="users" sortable={false} link=""> + <BooleanField source="erased" sortable={false} label="resources.users.fields.erased" /> + </ReferenceField> + </DatagridConfigurable> + )} </List> ); }; diff --git a/src/resources/users.tsx b/src/resources/users.tsx index ffd600e4..7e738d8f 100644 --- a/src/resources/users.tsx +++ b/src/resources/users.tsx @@ -12,15 +12,21 @@ import PersonPinIcon from "@mui/icons-material/PersonPin"; import ScienceIcon from "@mui/icons-material/Science"; import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent"; import ViewListIcon from "@mui/icons-material/ViewList"; -import { Alert, Box, Divider, Paper, Typography } from "@mui/material"; +import BlockIcon from "@mui/icons-material/Block"; +import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; +import LockIcon from "@mui/icons-material/Lock"; +import NoAccountsIcon from "@mui/icons-material/NoAccounts"; +import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import { Alert, Box, Divider, List as MuiList, ListItemButton, Paper, Tooltip, Typography } from "@mui/material"; import EmptyState from "../components/EmptyState"; import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { useEffect, useState } from "react"; import { ArrayInput, ArrayField, Button, - Datagrid, DatagridConfigurable, DateField, Create, @@ -58,7 +64,6 @@ import { BulkDeleteButton, TopToolbar, Toolbar, - NumberField, useListContext, useNotify, Identifier, @@ -69,6 +74,8 @@ import { useCreate, useRedirect, useLocale, + SimpleList, + useGetMany, } from "react-admin"; import { useFormContext } from "react-hook-form"; import { Link } from "react-router-dom"; @@ -99,6 +106,7 @@ import { GetConfig } from "../utils/config"; import { DATE_FORMAT } from "../utils/date"; import { decodeURLComponent } from "../utils/safety"; import { isASManaged } from "../utils/mxid"; +import { formatBytes } from "../utils/formatBytes"; import { generateRandomPassword } from "../utils/password"; const choices_medium = [ @@ -218,6 +226,8 @@ const UserBulkActionButtons = () => { export const UserList = (props: ListProps) => { const locale = useLocale(); const translate = useTranslate(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); useDocTitle(translate("resources.users.name", { smart_count: 2 })); return ( <List @@ -230,47 +240,98 @@ export const UserList = (props: ListProps) => { perPage={50} empty={<EmptyState />} > - <DatagridConfigurable - rowClick={(id: Identifier, resource: string) => `/${resource}/${encodeURIComponent(id)}`} - bulkActionButtons={<UserBulkActionButtons />} - > - <AvatarField - source="avatar_src" - sx={{ height: "40px", width: "40px" }} - sortBy="avatar_url" - label="resources.users.fields.avatar" - /> - <TextField - source="id" - sx={{ - wordBreak: "break-word", - overflowWrap: "break-word", - }} - sortBy="name" - label="resources.users.fields.id" - /> - <TextField - source="displayname" - sx={{ - wordBreak: "break-word", - overflowWrap: "break-word", - }} - label="resources.users.fields.displayname" - /> - <BooleanField source="is_guest" label="resources.users.fields.is_guest" /> - <BooleanField source="admin" label="resources.users.fields.admin" /> - <BooleanField source="deactivated" label="resources.users.fields.deactivated" /> - <BooleanField source="locked" label="resources.users.fields.locked" /> - <BooleanField source="shadow_banned" label="resources.users.fields.shadow_banned" /> - <BooleanField source="erased" sortable={false} label="resources.users.fields.erased" /> - <DateField - source="creation_ts" - label="resources.users.fields.creation_ts_ms" - showTime - options={DATE_FORMAT} - locales={locale} + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.displayname || record.id} + </Box> + )} + secondaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.id} + </Box> + )} + tertiaryText={record => ( + <Box sx={{ display: "flex", gap: 0.5 }}> + {record.admin && ( + <Tooltip title={translate("resources.users.fields.admin")}> + <AdminPanelSettingsIcon fontSize="small" color="primary" /> + </Tooltip> + )} + {record.locked && ( + <Tooltip title={translate("resources.users.fields.locked")}> + <LockIcon fontSize="small" color="warning" /> + </Tooltip> + )} + {record.suspended && ( + <Tooltip title={translate("resources.users.fields.suspended")}> + <BlockIcon fontSize="small" color="warning" /> + </Tooltip> + )} + {record.shadow_banned && ( + <Tooltip title={translate("resources.users.fields.shadow_banned")}> + <VisibilityOffIcon fontSize="small" color="warning" /> + </Tooltip> + )} + {record.deactivated && ( + <Tooltip title={translate("resources.users.fields.deactivated")}> + <NoAccountsIcon fontSize="small" color="error" /> + </Tooltip> + )} + {record.erased && ( + <Tooltip title={translate("resources.users.fields.erased")}> + <DeleteForeverIcon fontSize="small" color="error" /> + </Tooltip> + )} + </Box> + )} + linkType="edit" + leftAvatar={record => ( + <AvatarField record={record} source="avatar_src" sx={{ height: "40px", width: "40px" }} /> + )} /> - </DatagridConfigurable> + ) : ( + <DatagridConfigurable + rowClick={(id: Identifier, resource: string) => `/${resource}/${encodeURIComponent(id)}`} + bulkActionButtons={<UserBulkActionButtons />} + > + <AvatarField + source="avatar_src" + sx={{ height: "40px", width: "40px" }} + sortBy="avatar_url" + label="resources.users.fields.avatar" + /> + <TextField + source="id" + sx={{ + wordBreak: "break-all", + }} + sortBy="name" + label="resources.users.fields.id" + /> + <TextField + source="displayname" + sx={{ + wordBreak: "break-all", + }} + label="resources.users.fields.displayname" + /> + <BooleanField source="is_guest" label="resources.users.fields.is_guest" /> + <BooleanField source="admin" label="resources.users.fields.admin" /> + <BooleanField source="deactivated" label="resources.users.fields.deactivated" /> + <BooleanField source="locked" label="resources.users.fields.locked" /> + <BooleanField source="shadow_banned" label="resources.users.fields.shadow_banned" /> + <BooleanField source="erased" sortable={false} label="resources.users.fields.erased" /> + <DateField + source="creation_ts" + label="resources.users.fields.creation_ts_ms" + showTime + options={DATE_FORMAT} + locales={locale} + /> + </DatagridConfigurable> + )} </List> ); }; @@ -295,7 +356,7 @@ const UserEditActions = () => { } return ( - <TopToolbar> + <TopToolbar sx={{ flexWrap: "wrap", gap: 0.5, whiteSpace: "normal" }}> {!record?.deactivated && <LoginAsUserButton />} {!record?.deactivated && <ResetPasswordButton />} {!record?.deactivated && <AllowCrossSigningButton />} @@ -483,6 +544,7 @@ const UserEditToolbar = () => { }; const UserBooleanInput = props => { + const translate = useTranslate(); const record = useRecordContext(); const ownUserId = localStorage.getItem("user_id"); let ownUserIsSelected = false; @@ -496,9 +558,17 @@ const UserBooleanInput = props => { } } + const { icon, ...rest } = props; + const label = icon ? ( + <Box sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}> + {icon} + {translate(rest.label || `resources.users.fields.${rest.source}`)} + </Box> + ) : undefined; + return ( <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}> - <BooleanInput disabled={ownUserIsSelected || asManagedUserIsSelected} {...props} /> + <BooleanInput disabled={ownUserIsSelected || asManagedUserIsSelected} {...rest} {...(label ? { label } : {})} /> </UserPreventSelfDelete> ); }; @@ -579,9 +649,90 @@ const ErasedBooleanInput = props => { return <UserBooleanInput disabled={!deactivated} {...props} />; }; +const JoinedRoomsMobileList = () => { + const { data: joinedRooms } = useListContext(); + const translate = useTranslate(); + const ids = (joinedRooms || []).map(r => r.id); + const { data: rooms } = useGetMany("rooms", { ids }, { enabled: ids.length > 0 }); + const roomMap = new Map((rooms || []).map(r => [r.id, r])); + + if (!joinedRooms?.length) return null; + + return ( + <MuiList disablePadding> + {joinedRooms.map(record => { + const room = roomMap.get(record.id); + return ( + <ListItemButton + key={record.id as string} + component={Link} + to={"/rooms/" + record.id + "/show"} + sx={{ gap: 1, alignItems: "center" }} + > + <AvatarField record={room || record} source="avatar" sx={{ height: "40px", width: "40px" }} /> + <Box sx={{ flex: 1, minWidth: 0 }}> + <Typography variant="body1" sx={{ wordBreak: "break-all" }}> + {room?.name || room?.canonical_alias || record.id} + </Typography> + <Typography variant="body2" color="text.secondary"> + {translate("resources.rooms.fields.joined_members")}: {room?.joined_members ?? 0} + {room?.creator && ( + <> + <br /> + <Box component="span" sx={{ wordBreak: "break-all" }}> + {translate("resources.rooms.fields.creator")}: {room.creator} + </Box> + </> + )} + </Typography> + </Box> + </ListItemButton> + ); + })} + </MuiList> + ); +}; + +const MembershipsMobileList = () => { + const { data: memberships } = useListContext(); + const translate = useTranslate(); + const ids = (memberships || []).map(r => r.id); + const { data: rooms } = useGetMany("rooms", { ids }, { enabled: ids.length > 0 }); + const roomMap = new Map((rooms || []).map(r => [r.id, r])); + + if (!memberships?.length) return null; + + return ( + <MuiList disablePadding> + {memberships.map(record => { + const room = roomMap.get(record.id); + return ( + <ListItemButton + key={record.id as string} + component={Link} + to={"/rooms/" + record.id + "/show"} + sx={{ gap: 1, alignItems: "center" }} + > + <AvatarField record={room || record} source="avatar" sx={{ height: "40px", width: "40px" }} /> + <Box sx={{ flex: 1, minWidth: 0 }}> + <Typography variant="body1" sx={{ wordBreak: "break-all" }}> + {room?.name || record.id} + </Typography> + <Typography variant="body2" color="text.secondary"> + {translate("resources.users.membership", { smart_count: 1 })}: {record.membership} + </Typography> + </Box> + </ListItemButton> + ); + })} + </MuiList> + ); +}; + export const UserEdit = (props: EditProps) => { const translate = useTranslate(); const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("sm")); const locale = useLocale(); return ( @@ -590,13 +741,14 @@ export const UserEdit = (props: EditProps) => { title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic" + sx={{ "& .RaEdit-card": { maxWidth: { xs: "100vw", sm: "calc(100vw - 32px)" }, overflowX: "auto" } }} queryOptions={{ meta: { include: ["features"], // Tell your dataProvider to include features }, }} > - <TabbedForm toolbar={<UserEditToolbar />}> + <TabbedForm toolbar={<UserEditToolbar />} sx={{ "& .MuiTabs-scroller": { overflowX: "auto !important" } }}> <FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}> <Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" }, gap: 4, width: "100%", mb: 2, mt: 1 }} @@ -605,7 +757,7 @@ export const UserEdit = (props: EditProps) => { sx={{ display: "flex", flexDirection: "column", - alignItems: { xs: "flex-start", sm: "center" }, + alignItems: "center", minWidth: 140, gap: 2, }} @@ -629,12 +781,21 @@ export const UserEdit = (props: EditProps) => { <Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" }, gap: 4, width: "100%" }}> <Box sx={{ flex: 1 }}> - <UserBooleanInput source="suspended" helperText="resources.users.helper.suspend" /> - <UserBooleanInput source="shadow_banned" helperText="resources.users.helper.shadow_ban" /> + <UserBooleanInput + source="suspended" + helperText="resources.users.helper.suspend" + icon={<BlockIcon fontSize="small" />} + /> + <UserBooleanInput + source="shadow_banned" + helperText="resources.users.helper.shadow_ban" + icon={<VisibilityOffIcon fontSize="small" />} + /> <UserBooleanInput sx={{ color: theme.palette.warning.main }} source="locked" helperText="resources.users.helper.lock" + icon={<LockIcon fontSize="small" />} /> </Box> <Paper @@ -649,16 +810,22 @@ export const UserEdit = (props: EditProps) => { <Typography variant="subtitle2" color="error" sx={{ mb: 1 }}> {translate("synapseadmin.users.danger_zone")} </Typography> - <BooleanInput source="admin" helperText="resources.users.helper.admin" /> + <UserBooleanInput + source="admin" + helperText="resources.users.helper.admin" + icon={<AdminPanelSettingsIcon fontSize="small" />} + /> <UserBooleanInput sx={{ color: theme.palette.error.main }} source="deactivated" helperText="resources.users.helper.deactivate" + icon={<NoAccountsIcon fontSize="small" />} /> <ErasedBooleanInput sx={{ color: theme.palette.error.main, marginLeft: "25px" }} source="erased" helperText="resources.users.helper.erase" + icon={<DeleteForeverIcon fontSize="small" />} /> </Paper> </Box> @@ -692,20 +859,46 @@ export const UserEdit = (props: EditProps) => { perPage={10} > <Box sx={{ width: "100%" }}> - <DatagridConfigurable - bulkActionButtons={<DeviceBulkRemoveButton />} - omit={["last_seen_user_agent", "dehydrated"]} - > - <TextField source="device_id" sortable={false} /> - <DeviceDisplayNameInput /> - <TextField source="last_seen_ip" sortable={false} /> - <TextField source="last_seen_user_agent" sortable={false} /> - <DateField source="last_seen_ts" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> - <BooleanField source="dehydrated" sortable={false} /> - <WrapperField label="resources.rooms.fields.actions"> - <DeviceRemoveButton /> - </WrapperField> - </DatagridConfigurable> + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.device_id} + </Box> + )} + secondaryText={record => ( + <> + {record.last_seen_ip && ( + <> + {record.last_seen_ip} + <br /> + </> + )} + {record.last_seen_ts && new Date(record.last_seen_ts).toLocaleString(locale)} + <Box sx={{ mt: 1 }}> + <DeviceDisplayNameInput /> + </Box> + </> + )} + tertiaryText={() => <DeviceRemoveButton />} + linkType={false} + /> + ) : ( + <DatagridConfigurable + bulkActionButtons={<DeviceBulkRemoveButton />} + omit={["last_seen_user_agent", "dehydrated"]} + > + <TextField source="device_id" sortable={false} /> + <DeviceDisplayNameInput /> + <TextField source="last_seen_ip" sortable={false} /> + <TextField source="last_seen_user_agent" sortable={false} /> + <DateField source="last_seen_ts" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> + <BooleanField source="dehydrated" sortable={false} /> + <WrapperField label="resources.rooms.fields.actions"> + <DeviceRemoveButton /> + </WrapperField> + </DatagridConfigurable> + )} </Box> </ReferenceManyField> </FormTab> @@ -713,11 +906,31 @@ export const UserEdit = (props: EditProps) => { <FormTab label="resources.connections.name" icon={<SettingsInputComponentIcon />} path="connections"> <ReferenceField reference="connections" source="id" label={false} link={false}> <ArrayField source="devices[].sessions[0].connections" label="resources.connections.name"> - <Datagrid sx={{ width: "100%" }} bulkActionButtons={false}> - <TextField source="ip" sortable={false} /> - <DateField source="last_seen" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> - <TextField source="user_agent" sortable={false} style={{ width: "100%" }} /> - </Datagrid> + {isSmall ? ( + <SimpleList + primaryText={record => record.ip} + secondaryText={record => ( + <> + {record.last_seen && new Date(record.last_seen).toLocaleString(locale)} + {record.user_agent && ( + <> + <br /> + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.user_agent} + </Box> + </> + )} + </> + )} + linkType={false} + /> + ) : ( + <DatagridConfigurable sx={{ width: "100%" }} bulkActionButtons={false}> + <TextField source="ip" sortable={false} /> + <DateField source="last_seen" showTime options={DATE_FORMAT} sortable={false} locales={locale} /> + <TextField source="user_agent" sortable={false} style={{ width: "100%" }} /> + </DatagridConfigurable> + )} </ArrayField> </ReferenceField> </FormTab> @@ -736,21 +949,47 @@ export const UserEdit = (props: EditProps) => { perPage={10} sort={{ field: "created_ts", order: "DESC" }} > - <Datagrid sx={{ width: "100%" }} bulkActionButtons={<BulkDeleteButton />}> - <MediaIDField source="media_id" /> - <DateField source="created_ts" showTime options={DATE_FORMAT} locales={locale} /> - <DateField source="last_access_ts" showTime options={DATE_FORMAT} locales={locale} /> - <NumberField source="media_length" /> - <TextField source="media_type" sx={{ display: "block", width: 200, wordBreak: "break-word" }} /> - <FunctionField - source="upload_name" - render={record => (record.upload_name ? decodeURLComponent(record.upload_name) : "")} + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.upload_name ? decodeURLComponent(record.upload_name) : record.media_id} + </Box> + )} + secondaryText={record => ( + <> + {formatBytes(record.media_length)} + {record.media_type && <> · {record.media_type}</>} + <br /> + {new Date(record.created_ts).toLocaleString(locale)} + </> + )} + tertiaryText={() => ( + <Box sx={{ display: "flex", gap: 0.5 }}> + <QuarantineMediaButton /> + <ProtectMediaButton /> + <DeleteButton mutationMode="pessimistic" redirect={false} /> + </Box> + )} + linkType={false} /> - <TextField source="quarantined_by" /> - <QuarantineMediaButton /> - <ProtectMediaButton /> - <DeleteButton mutationMode="pessimistic" redirect={false} /> - </Datagrid> + ) : ( + <DatagridConfigurable sx={{ width: "100%" }} bulkActionButtons={<BulkDeleteButton />}> + <MediaIDField source="media_id" /> + <DateField source="created_ts" showTime options={DATE_FORMAT} locales={locale} /> + <DateField source="last_access_ts" showTime options={DATE_FORMAT} locales={locale} /> + <FunctionField source="media_length" render={record => formatBytes(record.media_length)} /> + <TextField source="media_type" sx={{ display: "block", width: 200, wordBreak: "break-word" }} /> + <FunctionField + source="upload_name" + render={record => (record.upload_name ? decodeURLComponent(record.upload_name) : "")} + /> + <TextField source="quarantined_by" /> + <QuarantineMediaButton /> + <ProtectMediaButton /> + <DeleteButton mutationMode="pessimistic" redirect={false} /> + </DatagridConfigurable> + )} </ReferenceManyField> </FormTab> @@ -762,43 +1001,52 @@ export const UserEdit = (props: EditProps) => { perPage={10} pagination={<Pagination />} > - <Datagrid - sx={{ width: "100%" }} - rowClick={id => "/rooms/" + id + "/show"} - bulkActionButtons={<RoomBulkActionButtons />} - > - <ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}> - <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> - </ReferenceField> - <TextField source="id" label="resources.rooms.fields.room_id" sortable={false} /> - <ReferenceField - reference="rooms" - source="id" - label="resources.rooms.fields.name" - link={false} - sortable={false} + {isSmall ? ( + <JoinedRoomsMobileList /> + ) : ( + <DatagridConfigurable + sx={{ width: "100%" }} + rowClick={id => "/rooms/" + id + "/show"} + bulkActionButtons={<RoomBulkActionButtons />} > + <ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}> + <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> + </ReferenceField> <TextField - source="name" - sx={{ - wordBreak: "break-word", - overflowWrap: "break-word", - }} + source="id" + label="resources.rooms.fields.room_id" + sortable={false} + sx={{ wordBreak: "break-all" }} /> - </ReferenceField> - <ReferenceField - reference="rooms" - source="id" - label="resources.rooms.fields.joined_members" - link={false} - sortable={false} - > - <TextField source="joined_members" sortable={false} /> - </ReferenceField> - <ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}> - <MakeAdminBtn /> - </ReferenceField> - </Datagrid> + <ReferenceField + reference="rooms" + source="id" + label="resources.rooms.fields.name" + link={false} + sortable={false} + > + <TextField + source="name" + sx={{ + wordBreak: "break-word", + overflowWrap: "break-word", + }} + /> + </ReferenceField> + <ReferenceField + reference="rooms" + source="id" + label="resources.rooms.fields.joined_members" + link={false} + sortable={false} + > + <TextField source="joined_members" sortable={false} /> + </ReferenceField> + <ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}> + <MakeAdminBtn /> + </ReferenceField> + </DatagridConfigurable> + )} </ReferenceManyField> </FormTab> @@ -814,26 +1062,39 @@ export const UserEdit = (props: EditProps) => { perPage={10} pagination={<Pagination />} > - <Datagrid sx={{ width: "100%" }} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={false}> - <ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}> - <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> - </ReferenceField> - <TextField source="id" label="resources.rooms.fields.room_id" sortable={false} /> - <ReferenceField - reference="rooms" - source="id" - label="resources.rooms.fields.name" - link={false} - sortable={false} + {isSmall ? ( + <MembershipsMobileList /> + ) : ( + <DatagridConfigurable + sx={{ width: "100%" }} + rowClick={id => "/rooms/" + id + "/show"} + bulkActionButtons={false} > - <TextField source="name" /> - </ReferenceField> - <TextField - source="membership" - label={translate("resources.users.membership", { smart_count: 1 })} - sortable={false} - /> - </Datagrid> + <ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}> + <AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} /> + </ReferenceField> + <TextField + source="id" + label="resources.rooms.fields.room_id" + sortable={false} + sx={{ wordBreak: "break-all" }} + /> + <ReferenceField + reference="rooms" + source="id" + label="resources.rooms.fields.name" + link={false} + sortable={false} + > + <TextField source="name" /> + </ReferenceField> + <TextField + source="membership" + label={translate("resources.users.membership", { smart_count: 1 })} + sortable={false} + /> + </DatagridConfigurable> + )} </ReferenceManyField> </FormTab> @@ -849,16 +1110,45 @@ export const UserEdit = (props: EditProps) => { pagination={<Pagination />} perPage={10} > - <Datagrid sx={{ width: "100%" }} bulkActionButtons={false}> - <TextField source="kind" sortable={false} /> - <TextField source="app_display_name" sortable={false} /> - <TextField source="app_id" sortable={false} /> - <TextField source="data.url" sortable={false} /> - <TextField source="device_display_name" sortable={false} /> - <TextField source="lang" sortable={false} /> - <TextField source="profile_tag" sortable={false} /> - <TextField source="pushkey" sortable={false} /> - </Datagrid> + {isSmall ? ( + <SimpleList + primaryText={record => ( + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.app_display_name || record.app_id} + </Box> + )} + secondaryText={record => ( + <> + {record.kind} + {record.device_display_name && <> · {record.device_display_name}</>} + {record.pushkey && ( + <> + <br /> + <Box component="span" sx={{ wordBreak: "break-all" }}> + {record.pushkey} + </Box> + </> + )} + </> + )} + linkType={false} + /> + ) : ( + <DatagridConfigurable + sx={{ width: "100%" }} + bulkActionButtons={false} + omit={["app_id", "data.url", "profile_tag", "pushkey"]} + > + <TextField source="kind" sortable={false} /> + <TextField source="app_display_name" sortable={false} /> + <TextField source="app_id" sortable={false} /> + <TextField source="data.url" sortable={false} /> + <TextField source="device_display_name" sortable={false} /> + <TextField source="lang" sortable={false} /> + <TextField source="profile_tag" sortable={false} /> + <TextField source="pushkey" sortable={false} /> + </DatagridConfigurable> + )} </ReferenceManyField> </FormTab> diff --git a/src/utils/formatBytes.ts b/src/utils/formatBytes.ts new file mode 100644 index 00000000..7fd605bc --- /dev/null +++ b/src/utils/formatBytes.ts @@ -0,0 +1,8 @@ +const units = ["B", "KB", "MB", "GB", "TB"]; + +export const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 B"; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const index = Math.min(i, units.length - 1); + return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +};