feat: style app connections
This commit is contained in:
@@ -11,7 +11,8 @@ const connectionType = new GraphQLObjectType({
|
||||
key: { type: GraphQLString },
|
||||
data: { type: connectionDataType },
|
||||
verified: { type: GraphQLBoolean },
|
||||
app: { type: appType }
|
||||
app: { type: appType },
|
||||
createdAt: { type: GraphQLString }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@@ -13,12 +13,14 @@
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/lodash.template": "^4.5.0",
|
||||
"@types/luxon": "^2.0.8",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"graphql": "^15.6.0",
|
||||
"lodash.template": "^4.5.0",
|
||||
"luxon": "^2.2.0",
|
||||
"notistack": "^2.0.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
|
@@ -2,9 +2,11 @@ import * as React from 'react';
|
||||
import { useLazyQuery, useMutation } from '@apollo/client';
|
||||
import Card from '@mui/material/Card';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { DELETE_CONNECTION } from 'graphql/mutations/delete-connection';
|
||||
import { TEST_CONNECTION } from 'graphql/queries/test-connection';
|
||||
@@ -17,7 +19,14 @@ type AppConnectionRowProps = {
|
||||
connection: Connection;
|
||||
}
|
||||
|
||||
const countTranslation = (value: React.ReactNode) => (<><strong>{value}</strong><br /></>);
|
||||
const countTranslation = (value: React.ReactNode) => (
|
||||
<>
|
||||
<Typography variant="body1">
|
||||
{value}
|
||||
</Typography>
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
|
||||
function AppConnectionRow(props: AppConnectionRowProps) {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
@@ -25,7 +34,7 @@ function AppConnectionRow(props: AppConnectionRowProps) {
|
||||
const [deleteConnection] = useMutation(DELETE_CONNECTION);
|
||||
|
||||
const formatMessage = useFormatMessage();
|
||||
const { id, key, data, verified } = props.connection;
|
||||
const { id, key, data, verified, createdAt } = props.connection;
|
||||
|
||||
const contextButtonRef = React.useRef<SVGSVGElement | null>(null);
|
||||
const [anchorEl, setAnchorEl] = React.useState<SVGSVGElement | null>(null);
|
||||
@@ -57,23 +66,34 @@ function AppConnectionRow(props: AppConnectionRowProps) {
|
||||
}
|
||||
}, [deleteConnection, id, testConnection, formatMessage, enqueueSnackbar]);
|
||||
|
||||
const relativeCreatedAt = DateTime.fromMillis(parseInt(createdAt, 10)).toRelative();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card sx={{ my: 2 }}>
|
||||
<CardActionArea onClick={onContextMenuClick}>
|
||||
<CardContent>
|
||||
<Box>
|
||||
<Stack
|
||||
direction="column"
|
||||
justifyContent="center"
|
||||
alignItems="flex-start"
|
||||
spacing={1}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
{data.screenName}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption">
|
||||
{formatMessage('connection.addedAt', { datetime: relativeCreatedAt })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
{testCalled && !testLoading && (verified ? 'yes' : 'no')}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<Typography variant="caption" color="textSecondary" sx={{ display: ['none', 'inline-block'] }}>
|
||||
{formatMessage('connection.flowCount', { count: countTranslation(0) })}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
@@ -1,18 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
|
||||
|
||||
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
|
||||
import AppConnectionRow from 'components/AppConnectionRow';
|
||||
import NoResultFound from 'components/NoResultFound';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import type { Connection } from 'types/connection';
|
||||
import * as URLS from 'config/urls';
|
||||
|
||||
type AppConnectionsProps = {
|
||||
appKey: String;
|
||||
appKey: string;
|
||||
}
|
||||
|
||||
export default function AppConnections(props: AppConnectionsProps) {
|
||||
const { appKey } = props;
|
||||
const formatMessage = useFormatMessage();
|
||||
const { data } = useQuery(GET_APP_CONNECTIONS, { variables: { key: appKey } });
|
||||
const appConnections: Connection[] = data?.getApp?.connections || [];
|
||||
|
||||
const hasConnections = appConnections?.length;
|
||||
|
||||
if (!hasConnections) {
|
||||
return (
|
||||
<NoResultFound
|
||||
to={URLS.APP_ADD_CONNECTION(appKey)}
|
||||
text={formatMessage('app.noConnections')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{appConnections.map((appConnection: Connection) => (
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import type { AvatarProps } from '@mui/material/Avatar';
|
||||
|
||||
type AppIconProps = {
|
||||
name: string;
|
||||
@@ -11,18 +12,19 @@ const inlineImgStyle: React.CSSProperties = {
|
||||
objectFit: 'contain',
|
||||
};
|
||||
|
||||
export default function AppIcon(props: AppIconProps) {
|
||||
const { name, url } = props;
|
||||
export default function AppIcon(props: AppIconProps & AvatarProps) {
|
||||
const { name, url, sx = {}, ...restProps } = props;
|
||||
const color = url ? 'white' : props.color
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
component="span"
|
||||
variant="square"
|
||||
sx={{ bgcolor: `#${color}` }}
|
||||
sx={{ bgcolor: `#${color}`, display: 'inline-flex', width: 50, height: 50, ...sx }}
|
||||
imgProps={{ style: inlineImgStyle }}
|
||||
src={url}
|
||||
alt={name}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@@ -22,6 +22,12 @@ export default function Drawer(props: SwipeableDrawerProps) {
|
||||
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { noSsr: true });
|
||||
const formatMessage = useFormatMessage();
|
||||
|
||||
const closeOnClick = (event: React.SyntheticEvent) => {
|
||||
if (matchSmallScreens) {
|
||||
props.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDrawer
|
||||
{...props}
|
||||
@@ -38,18 +44,21 @@ export default function Drawer(props: SwipeableDrawerProps) {
|
||||
icon={<SwapCallsIcon htmlColor={theme.palette.primary.main} />}
|
||||
primary={formatMessage('drawer.flows')}
|
||||
to={URLS.FLOWS}
|
||||
onClick={closeOnClick}
|
||||
/>
|
||||
|
||||
<ListItemLink
|
||||
icon={<AppsIcon htmlColor={theme.palette.primary.main} />}
|
||||
primary={formatMessage('drawer.apps')}
|
||||
to={URLS.APPS}
|
||||
onClick={closeOnClick}
|
||||
/>
|
||||
|
||||
<ListItemLink
|
||||
icon={<ExploreIcon htmlColor={theme.palette.primary.main} />}
|
||||
primary={formatMessage('drawer.explore')}
|
||||
to={URLS.EXPLORE}
|
||||
onClick={closeOnClick}
|
||||
/>
|
||||
</List>
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useMemo, forwardRef } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import ListItem from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
@@ -9,15 +9,16 @@ type ListItemLinkProps = {
|
||||
icon: React.ReactNode;
|
||||
primary: string;
|
||||
to: string;
|
||||
onClick?: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
export default function ListItemLink(props: ListItemLinkProps) {
|
||||
const { icon, primary, to } = props;
|
||||
const { icon, primary, to, onClick } = props;
|
||||
const selected = useMatch({ path: to, end: false });
|
||||
|
||||
const CustomLink = useMemo(
|
||||
const CustomLink = React.useMemo(
|
||||
() =>
|
||||
forwardRef<HTMLAnchorElement, Omit<LinkProps, 'to'>>(function InLineLink(
|
||||
React.forwardRef<HTMLAnchorElement, Omit<LinkProps, 'to'>>(function InLineLink(
|
||||
linkProps,
|
||||
ref,
|
||||
) {
|
||||
@@ -28,7 +29,12 @@ export default function ListItemLink(props: ListItemLinkProps) {
|
||||
|
||||
return (
|
||||
<li>
|
||||
<ListItem component={CustomLink} sx={{ pl: { xs: 2, sm: 3 } }} selected={!!selected}>
|
||||
<ListItem
|
||||
component={CustomLink}
|
||||
sx={{ pl: { xs: 2, sm: 3 } }}
|
||||
selected={!!selected}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 52 }}>{icon}</ListItemIcon>
|
||||
<ListItemText primary={primary} primaryTypographyProps={{ variant: 'body1', }} />
|
||||
</ListItem>
|
||||
|
42
packages/web/src/components/NoResultFound/index.tsx
Normal file
42
packages/web/src/components/NoResultFound/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { LinkProps } from 'react-router-dom';
|
||||
import Card from '@mui/material/Card';
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { CardContent } from './style';
|
||||
|
||||
type NoResultFoundProps = {
|
||||
text?: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export default function NoResultFound(props: NoResultFoundProps) {
|
||||
const { text, to } = props;
|
||||
|
||||
const ActionAreaLink = React.useMemo(
|
||||
() =>
|
||||
React.forwardRef<HTMLAnchorElement, Omit<LinkProps, 'to'>>(function InlineLink(
|
||||
linkProps,
|
||||
ref,
|
||||
) {
|
||||
return <Link ref={ref} to={to} {...linkProps} />;
|
||||
}),
|
||||
[to],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card elevation={0}>
|
||||
<CardActionArea component={ActionAreaLink} {...props}>
|
||||
<CardContent>
|
||||
<AddCircleIcon color="primary" />
|
||||
|
||||
<Typography variant="body1">
|
||||
{text}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
)
|
||||
};
|
11
packages/web/src/components/NoResultFound/style.ts
Normal file
11
packages/web/src/components/NoResultFound/style.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import MuiCardContent from '@mui/material/CardContent';
|
||||
|
||||
export const CardContent = styled(MuiCardContent)`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
min-height: 200px;
|
||||
`;
|
@@ -11,6 +11,7 @@ export const GET_APP_CONNECTIONS = gql`
|
||||
data {
|
||||
screenName
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -9,9 +9,12 @@
|
||||
"app.connectionCount": "{count} connections",
|
||||
"app.flowCount": "{count} flows",
|
||||
"app.addConnection": "Add connection",
|
||||
"app.createFlow": "Create flow",
|
||||
"app.settings": "Settings",
|
||||
"app.connections": "Connections",
|
||||
"app.noConnections": "You don't have any connections yet.",
|
||||
"app.flows": "Flows",
|
||||
"app.noFlows": "You don't have any flows yet.",
|
||||
"apps.title": "My Apps",
|
||||
"apps.addConnection": "Add connection",
|
||||
"apps.addNewAppConnection": "Add a new app connection",
|
||||
@@ -21,5 +24,7 @@
|
||||
"connection.testConnection": "Test connection",
|
||||
"connection.reconnect": "Reconnect",
|
||||
"connection.delete": "Delete",
|
||||
"connection.deletedMessage": "The connection has been deleted."
|
||||
"connection.deletedMessage": "The connection has been deleted.",
|
||||
"connection.addedAt": "Added {datetime}"
|
||||
|
||||
}
|
@@ -1,17 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { Link, Route, Navigate, Routes, useParams, useMatch, useNavigate } from 'react-router-dom';
|
||||
import type { LinkProps } from 'react-router-dom';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import Box from '@mui/material/Box';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import { GET_APP } from 'graphql/queries/get-app';
|
||||
import * as URLS from 'config/urls';
|
||||
|
||||
import ConditionalIconButton from 'components/ConditionalIconButton';
|
||||
import AppConnections from 'components/AppConnections';
|
||||
import AppFlows from 'components/AppFlows';
|
||||
import AddAppConnection from 'components/AddAppConnection';
|
||||
@@ -37,8 +40,9 @@ const ReconnectConnection = (props: any) => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function Application() {
|
||||
const theme = useTheme();
|
||||
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { noSsr: true });
|
||||
const formatMessage = useFormatMessage();
|
||||
const connectionsPathMatch = useMatch({ path: URLS.APP_CONNECTIONS_PATTERN, end: false });
|
||||
const flowsPathMatch = useMatch({ path: URLS.APP_FLOWS_PATTERN, end: false });
|
||||
@@ -49,34 +53,91 @@ export default function Application() {
|
||||
const goToApplicationPage = () => navigate('connections');
|
||||
const app = data?.getApp || {};
|
||||
|
||||
const NewConnectionLink = React.useMemo(
|
||||
() =>
|
||||
React.forwardRef<HTMLAnchorElement, Omit<LinkProps, 'to'>>(function InlineLink(
|
||||
linkProps,
|
||||
ref,
|
||||
) {
|
||||
return <Link ref={ref} to={URLS.APP_ADD_CONNECTION(appKey)} {...linkProps} />;
|
||||
}),
|
||||
[appKey],
|
||||
);
|
||||
|
||||
const NewFlowLink = React.useMemo(
|
||||
() =>
|
||||
React.forwardRef<HTMLAnchorElement, Omit<LinkProps, 'to'>>(function InlineLink(
|
||||
linkProps,
|
||||
ref,
|
||||
) {
|
||||
return <Link ref={ref} to={URLS.APP_ADD_CONNECTION(appKey)} {...linkProps} />;
|
||||
}),
|
||||
[appKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ py: 3 }}>
|
||||
<Container>
|
||||
<Grid container sx={{ mb: 3 }}>
|
||||
<Grid item xs="auto" sx={{ mr: 1.5 }}>
|
||||
<AppIcon url={app.iconUrl} color={app.primaryColor} name={app.name} />
|
||||
<Grid container sx={{ mb: 3 }} alignItems="center">
|
||||
<Grid item xs="auto" sx={{ mr: 3 }}>
|
||||
<AppIcon
|
||||
url={app.iconUrl}
|
||||
color={app.primaryColor}
|
||||
name={app.name}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs>
|
||||
<PageTitle>{app.name}</PageTitle>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs="auto" justifyContent="flex-end">
|
||||
<IconButton sx={{ mr: 2 }} title={formatMessage('app.settings')}>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
<Grid item xs="auto">
|
||||
<Routes>
|
||||
<Route
|
||||
path={`${URLS.FLOWS}/*`}
|
||||
element={
|
||||
<ConditionalIconButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
component={NewFlowLink}
|
||||
fullWidth
|
||||
icon={<AddIcon />}
|
||||
>
|
||||
{formatMessage('app.createFlow')}
|
||||
</ConditionalIconButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button variant="contained" component={Link} to={URLS.APP_ADD_CONNECTION(appKey)}>
|
||||
<Route
|
||||
path={`${URLS.CONNECTIONS}/*`}
|
||||
element={
|
||||
<ConditionalIconButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
component={NewConnectionLink}
|
||||
fullWidth
|
||||
icon={<AddIcon />}
|
||||
>
|
||||
{formatMessage('app.addConnection')}
|
||||
</Button>
|
||||
</ConditionalIconButton>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container>
|
||||
<Grid item xs>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tabs value={connectionsPathMatch?.pattern?.path || flowsPathMatch?.pattern?.path}>
|
||||
<Tabs
|
||||
variant={matchSmallScreens ? 'fullWidth' : undefined}
|
||||
value={connectionsPathMatch?.pattern?.path || flowsPathMatch?.pattern?.path}
|
||||
>
|
||||
<Tab
|
||||
label={formatMessage('app.connections')}
|
||||
to={URLS.APP_CONNECTIONS(appKey)}
|
||||
|
@@ -6,7 +6,6 @@ import Box from '@mui/material/Box';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
|
||||
|
||||
import ConditionalIconButton from 'components/ConditionalIconButton';
|
||||
import Container from 'components/Container';
|
||||
import AddNewAppConnection from 'components/AddNewAppConnection';
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Container from '@mui/material/Container';
|
||||
import Container from 'components/Container';
|
||||
|
||||
export default function Explore() {
|
||||
return (
|
||||
|
@@ -180,6 +180,9 @@ const extendedTheme = createTheme({
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'rgba(0, 8, 20, 0.64)'
|
||||
},
|
||||
invisible: {
|
||||
background: 'transparent',
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -243,6 +246,15 @@ const extendedTheme = createTheme({
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
[referenceTheme.breakpoints.up('sm')]: {
|
||||
padding: referenceTheme.spacing(1.5, 3),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
MuiToolbar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
|
@@ -7,6 +7,7 @@ type Connection = {
|
||||
key: string;
|
||||
data: ConnectionData;
|
||||
verified: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type { Connection, ConnectionData };
|
||||
|
10
yarn.lock
10
yarn.lock
@@ -3206,6 +3206,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.175.tgz#b78dfa959192b01fae0ad90e166478769b215f45"
|
||||
integrity sha512-XmdEOrKQ8a1Y/yxQFOMbC47G/V2VDO1GvMRnl4O75M4GW/abC5tnfzadQYkqEveqRM1dEJGFFegfPNA2vvx2iw==
|
||||
|
||||
"@types/luxon@^2.0.8":
|
||||
version "2.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-2.0.8.tgz#a0fdd7ab0b67e08bf1d301232a7fef79b74ded69"
|
||||
integrity sha512-lGmxL6hMEVqXr8w9bL52RUWXVu90o7vH8WQSutQssr2e+w0TNttXx2Zfw2V2lHHHWfW6OGqB8bXDvtKocv19qQ==
|
||||
|
||||
"@types/mime@^1":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
|
||||
@@ -10531,6 +10536,11 @@ lru-cache@^6.0.0:
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
luxon@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/luxon/-/luxon-2.2.0.tgz#f5c4a234ba4016f792488b11aaed2d5bc14c888e"
|
||||
integrity sha512-LwmknessH4jVIseCsizUgveIHwlLv/RQZWC2uDSMfGJs7w8faPUi2JFxfyfMcTPrpNbChTem3Uz6IKRtn+LcIA==
|
||||
|
||||
lz-string@^1.4.4:
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
|
||||
|
Reference in New Issue
Block a user