mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Actions: Improve drag and drop experience (#94263)
* feat(actions): improve dnd ux --------- Co-authored-by: Kristina Durivage <kristina.durivage@grafana.com>
This commit is contained in:
parent
8072286daf
commit
b189743ca0
@ -1,7 +1,7 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import { ReactNode, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { DataFrame, DataLink, GrafanaTheme2, VariableSuggestion } from '@grafana/data';
|
import { DataFrame, DataLink, GrafanaTheme2, VariableSuggestion } from '@grafana/data';
|
||||||
|
|
||||||
@ -91,49 +91,40 @@ export const DataLinksInlineEditor = ({
|
|||||||
onChange(update);
|
onChange(update);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFirstLink = (linkJSX: ReactNode, key: string) => {
|
return (
|
||||||
if (showOneClick) {
|
<div className={styles.container}>
|
||||||
return (
|
{/* one-link placeholder */}
|
||||||
<div className={styles.oneClickOverlay} key={key}>
|
{showOneClick && linksSafe.length > 0 && (
|
||||||
|
<div className={styles.oneClickOverlay}>
|
||||||
<span className={styles.oneClickSpan}>
|
<span className={styles.oneClickSpan}>
|
||||||
<Trans i18nKey="grafana-ui.data-links-inline-editor.one-click-link">One-click link</Trans>
|
<Trans i18nKey="grafana-ui.data-links-inline-editor.one-click-link">One-click link</Trans>
|
||||||
</span>
|
</span>
|
||||||
{linkJSX}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
}
|
|
||||||
return linkJSX;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<Droppable droppableId="sortable-links" direction="vertical">
|
<Droppable droppableId="sortable-links" direction="vertical">
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div className={styles.wrapper} ref={provided.innerRef} {...provided.droppableProps}>
|
<div
|
||||||
|
className={styles.wrapper}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
style={{ paddingTop: showOneClick && linksSafe.length > 0 ? '28px' : '0px' }}
|
||||||
|
>
|
||||||
{linksSafe.map((link, idx) => {
|
{linksSafe.map((link, idx) => {
|
||||||
const key = `${link.title}/${idx}`;
|
const key = `${link.title}/${idx}`;
|
||||||
|
return (
|
||||||
const linkJSX = (
|
<DataLinksListItem
|
||||||
<div className={styles.itemWrapper} key={key}>
|
key={key}
|
||||||
<DataLinksListItem
|
index={idx}
|
||||||
key={key}
|
link={link}
|
||||||
index={idx}
|
onChange={onDataLinkChange}
|
||||||
link={link}
|
onEdit={() => setEditIndex(idx)}
|
||||||
onChange={onDataLinkChange}
|
onRemove={() => onDataLinkRemove(idx)}
|
||||||
onEdit={() => setEditIndex(idx)}
|
data={data}
|
||||||
onRemove={() => onDataLinkRemove(idx)}
|
itemKey={key}
|
||||||
data={data}
|
/>
|
||||||
itemKey={key}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (idx === 0) {
|
|
||||||
return renderFirstLink(linkJSX, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return linkJSX;
|
|
||||||
})}
|
})}
|
||||||
{provided.placeholder}
|
{provided.placeholder}
|
||||||
</div>
|
</div>
|
||||||
@ -164,22 +155,27 @@ export const DataLinksInlineEditor = ({
|
|||||||
<Button size="sm" icon="plus" onClick={onDataLinkAdd} variant="secondary" className={styles.button}>
|
<Button size="sm" icon="plus" onClick={onDataLinkAdd} variant="secondary" className={styles.button}>
|
||||||
<Trans i18nKey="grafana-ui.data-links-inline-editor.add-link">Add link</Trans>
|
<Trans i18nKey="grafana-ui.data-links-inline-editor.add-link">Add link</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
|
const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
container: css({
|
||||||
|
position: 'relative',
|
||||||
|
}),
|
||||||
wrapper: css({
|
wrapper: css({
|
||||||
marginBottom: theme.spacing(2),
|
marginBottom: theme.spacing(2),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}),
|
}),
|
||||||
oneClickOverlay: css({
|
oneClickOverlay: css({
|
||||||
height: 'auto',
|
|
||||||
border: `2px dashed ${theme.colors.text.link}`,
|
border: `2px dashed ${theme.colors.text.link}`,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: theme.colors.text.primary,
|
color: theme.colors.text.primary,
|
||||||
marginBottom: theme.spacing(1),
|
marginBottom: theme.spacing(1),
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '92px',
|
||||||
}),
|
}),
|
||||||
oneClickSpan: css({
|
oneClickSpan: css({
|
||||||
padding: 10,
|
padding: 10,
|
||||||
@ -187,9 +183,6 @@ const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
|
|||||||
marginBottom: -10,
|
marginBottom: -10,
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
}),
|
}),
|
||||||
itemWrapper: css({
|
|
||||||
padding: '4px 8px 8px 8px',
|
|
||||||
}),
|
|
||||||
button: css({
|
button: css({
|
||||||
marginLeft: theme.spacing(1),
|
marginLeft: theme.spacing(1),
|
||||||
}),
|
}),
|
||||||
|
@ -5,10 +5,9 @@ import { DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
|
|||||||
|
|
||||||
import { useStyles2 } from '../../../themes';
|
import { useStyles2 } from '../../../themes';
|
||||||
import { isCompactUrl } from '../../../utils';
|
import { isCompactUrl } from '../../../utils';
|
||||||
import { Trans } from '../../../utils/i18n';
|
|
||||||
import { FieldValidationMessage } from '../../Forms/FieldValidationMessage';
|
|
||||||
import { Icon } from '../../Icon/Icon';
|
import { Icon } from '../../Icon/Icon';
|
||||||
import { IconButton } from '../../IconButton/IconButton';
|
import { IconButton } from '../../IconButton/IconButton';
|
||||||
|
import { Tooltip } from '../../Tooltip/Tooltip';
|
||||||
|
|
||||||
export interface DataLinksListItemProps {
|
export interface DataLinksListItemProps {
|
||||||
index: number;
|
index: number;
|
||||||
@ -33,40 +32,33 @@ export const DataLinksListItem = ({ link, onEdit, onRemove, index, itemKey }: Da
|
|||||||
return (
|
return (
|
||||||
<Draggable key={itemKey} draggableId={itemKey} index={index}>
|
<Draggable key={itemKey} draggableId={itemKey} index={index}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<>
|
<div
|
||||||
<div
|
className={cx(styles.wrapper, styles.dragRow)}
|
||||||
className={cx(styles.wrapper, styles.dragRow)}
|
ref={provided.innerRef}
|
||||||
ref={provided.innerRef}
|
{...provided.draggableProps}
|
||||||
{...provided.draggableProps}
|
key={index}
|
||||||
key={index}
|
>
|
||||||
>
|
<div className={styles.linkDetails}>
|
||||||
<div className={styles.linkDetails}>
|
<div className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}>
|
||||||
<div className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}>
|
{hasTitle ? title : 'Data link title not provided'}
|
||||||
{hasTitle ? title : 'Data link title not provided'}
|
</div>
|
||||||
</div>
|
<Tooltip content={'Explore data link may not work in the future. Please edit.'} show={isCompactExploreUrl}>
|
||||||
<div
|
<div
|
||||||
className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}
|
className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}
|
||||||
title={url}
|
title={url}
|
||||||
>
|
>
|
||||||
{hasUrl ? url : 'Data link url not provided'}
|
{hasUrl ? url : 'Data link url not provided'}
|
||||||
</div>
|
</div>
|
||||||
{isCompactExploreUrl && (
|
</Tooltip>
|
||||||
<FieldValidationMessage>
|
</div>
|
||||||
<Trans i18nKey="grafana-ui.data-links-list-item.compact-explore-disclaimer">
|
<div className={styles.icons}>
|
||||||
Explore data link may not work in the future. Please edit.
|
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit data link" />
|
||||||
</Trans>
|
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove data link" />
|
||||||
</FieldValidationMessage>
|
<div className={styles.dragIcon} {...provided.dragHandleProps}>
|
||||||
)}
|
<Icon name="draggabledots" size="lg" />
|
||||||
</div>
|
|
||||||
<div className={styles.icons}>
|
|
||||||
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit data link" />
|
|
||||||
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove data link" />
|
|
||||||
<div className={styles.dragIcon} {...provided.dragHandleProps}>
|
|
||||||
<Icon name="draggabledots" size="lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
);
|
);
|
||||||
@ -79,7 +71,6 @@ const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
width: '100%',
|
|
||||||
padding: '5px 0 5px 10px',
|
padding: '5px 0 5px 10px',
|
||||||
borderRadius: theme.shape.radius.default,
|
borderRadius: theme.shape.radius.default,
|
||||||
background: theme.colors.background.secondary,
|
background: theme.colors.background.secondary,
|
||||||
@ -112,6 +103,7 @@ const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
|
|||||||
}),
|
}),
|
||||||
dragRow: css({
|
dragRow: css({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
margin: '8px',
|
||||||
}),
|
}),
|
||||||
icons: css({
|
icons: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import { ReactNode, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Action, DataFrame, GrafanaTheme2, defaultActionConfig, VariableSuggestion } from '@grafana/data';
|
import { Action, DataFrame, GrafanaTheme2, defaultActionConfig, VariableSuggestion } from '@grafana/data';
|
||||||
import { Button } from '@grafana/ui/src/components/Button';
|
import { Button } from '@grafana/ui/src/components/Button';
|
||||||
@ -90,46 +90,39 @@ export const ActionsInlineEditor = ({
|
|||||||
onChange(update);
|
onChange(update);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFirstAction = (actionsJSX: ReactNode, key: string) => {
|
|
||||||
if (showOneClick) {
|
|
||||||
return (
|
|
||||||
<div className={styles.oneClickOverlay} key={key}>
|
|
||||||
<span className={styles.oneClickSpan}>One-click action</span> {actionsJSX}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return actionsJSX;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={styles.container}>
|
||||||
|
{/* one-link placeholder */}
|
||||||
|
{showOneClick && actionsSafe.length > 0 && (
|
||||||
|
<div className={styles.oneClickOverlay}>
|
||||||
|
<span className={styles.oneClickSpan}>One-click link</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<Droppable droppableId="sortable-actions" direction="vertical">
|
<Droppable droppableId="sortable-actions" direction="vertical">
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div className={styles.wrapper} ref={provided.innerRef} {...provided.droppableProps}>
|
<div
|
||||||
|
className={styles.wrapper}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
style={{ paddingTop: showOneClick && actionsSafe.length > 0 ? '28px' : '0px' }}
|
||||||
|
>
|
||||||
{actionsSafe.map((action, idx) => {
|
{actionsSafe.map((action, idx) => {
|
||||||
const key = `${action.title}/${idx}`;
|
const key = `${action.title}/${idx}`;
|
||||||
|
|
||||||
const actionsJSX = (
|
return (
|
||||||
<div className={styles.itemWrapper} key={key}>
|
<ActionListItem
|
||||||
<ActionListItem
|
key={key}
|
||||||
key={key}
|
index={idx}
|
||||||
index={idx}
|
action={action}
|
||||||
action={action}
|
onChange={onActionChange}
|
||||||
onChange={onActionChange}
|
onEdit={() => setEditIndex(idx)}
|
||||||
onEdit={() => setEditIndex(idx)}
|
onRemove={() => onActionRemove(idx)}
|
||||||
onRemove={() => onActionRemove(idx)}
|
data={data}
|
||||||
data={data}
|
itemKey={key}
|
||||||
itemKey={key}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (idx === 0) {
|
|
||||||
return renderFirstAction(actionsJSX, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return actionsJSX;
|
|
||||||
})}
|
})}
|
||||||
{provided.placeholder}
|
{provided.placeholder}
|
||||||
</div>
|
</div>
|
||||||
@ -160,22 +153,27 @@ export const ActionsInlineEditor = ({
|
|||||||
<Button size="sm" icon="plus" onClick={onActionAdd} variant="secondary" className={styles.button}>
|
<Button size="sm" icon="plus" onClick={onActionAdd} variant="secondary" className={styles.button}>
|
||||||
Add action
|
Add action
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActionsInlineEditorStyle = (theme: GrafanaTheme2) => ({
|
const getActionsInlineEditorStyle = (theme: GrafanaTheme2) => ({
|
||||||
|
container: css({
|
||||||
|
position: 'relative',
|
||||||
|
}),
|
||||||
wrapper: css({
|
wrapper: css({
|
||||||
marginBottom: theme.spacing(2),
|
marginBottom: theme.spacing(2),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}),
|
}),
|
||||||
oneClickOverlay: css({
|
oneClickOverlay: css({
|
||||||
height: 'auto',
|
|
||||||
border: `2px dashed ${theme.colors.text.link}`,
|
border: `2px dashed ${theme.colors.text.link}`,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: theme.colors.text.primary,
|
color: theme.colors.text.primary,
|
||||||
marginBottom: theme.spacing(1),
|
marginBottom: theme.spacing(1),
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '89px',
|
||||||
}),
|
}),
|
||||||
oneClickSpan: css({
|
oneClickSpan: css({
|
||||||
padding: 10,
|
padding: 10,
|
||||||
|
@ -26,27 +26,25 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act
|
|||||||
return (
|
return (
|
||||||
<Draggable key={itemKey} draggableId={itemKey} index={index}>
|
<Draggable key={itemKey} draggableId={itemKey} index={index}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<>
|
<div
|
||||||
<div
|
className={cx(styles.wrapper, styles.dragRow)}
|
||||||
className={cx(styles.wrapper, styles.dragRow)}
|
ref={provided.innerRef}
|
||||||
ref={provided.innerRef}
|
{...provided.draggableProps}
|
||||||
{...provided.draggableProps}
|
key={index}
|
||||||
key={index}
|
>
|
||||||
>
|
<div className={styles.linkDetails}>
|
||||||
<div className={styles.linkDetails}>
|
<div className={cx(styles.url, !hasTitle && styles.notConfigured)}>
|
||||||
<div className={cx(styles.url, !hasTitle && styles.notConfigured)}>
|
{hasTitle ? title : 'Action title not provided'}
|
||||||
{hasTitle ? title : 'Action title not provided'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.icons}>
|
|
||||||
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action" />
|
|
||||||
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove action" />
|
|
||||||
<div className={styles.dragIcon} {...provided.dragHandleProps}>
|
|
||||||
<Icon name="draggabledots" size="lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className={styles.icons}>
|
||||||
|
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action" />
|
||||||
|
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove action" />
|
||||||
|
<div className={styles.dragIcon} {...provided.dragHandleProps}>
|
||||||
|
<Icon name="draggabledots" size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
);
|
);
|
||||||
@ -59,7 +57,6 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
width: '100%',
|
|
||||||
padding: '5px 0 5px 10px',
|
padding: '5px 0 5px 10px',
|
||||||
borderRadius: theme.shape.radius.default,
|
borderRadius: theme.shape.radius.default,
|
||||||
background: theme.colors.background.secondary,
|
background: theme.colors.background.secondary,
|
||||||
@ -100,6 +97,7 @@ const getActionListItemStyles = (theme: GrafanaTheme2) => {
|
|||||||
}),
|
}),
|
||||||
dragRow: css({
|
dragRow: css({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
margin: '8px',
|
||||||
}),
|
}),
|
||||||
icons: css({
|
icons: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -1084,9 +1084,6 @@
|
|||||||
"add-link": "Add link",
|
"add-link": "Add link",
|
||||||
"one-click-link": "One-click link"
|
"one-click-link": "One-click link"
|
||||||
},
|
},
|
||||||
"data-links-list-item": {
|
|
||||||
"compact-explore-disclaimer": "Explore data link may not work in the future. Please edit."
|
|
||||||
},
|
|
||||||
"data-source-http-settings": {
|
"data-source-http-settings": {
|
||||||
"access-help": "Help <1></1>",
|
"access-help": "Help <1></1>",
|
||||||
"access-help-details": "Access mode controls how requests to the data source will be handled.<1> <1>Server</1></1> should be the preferred way if nothing else is stated.",
|
"access-help-details": "Access mode controls how requests to the data source will be handled.<1> <1>Server</1></1> should be the preferred way if nothing else is stated.",
|
||||||
|
@ -1084,9 +1084,6 @@
|
|||||||
"add-link": "Åđđ ľįʼnĸ",
|
"add-link": "Åđđ ľįʼnĸ",
|
||||||
"one-click-link": "Øʼnę-čľįčĸ ľįʼnĸ"
|
"one-click-link": "Øʼnę-čľįčĸ ľįʼnĸ"
|
||||||
},
|
},
|
||||||
"data-links-list-item": {
|
|
||||||
"compact-explore-disclaimer": "Ēχpľőřę đäŧä ľįʼnĸ mäy ʼnőŧ ŵőřĸ įʼn ŧĥę ƒūŧūřę. Pľęäşę ęđįŧ."
|
|
||||||
},
|
|
||||||
"data-source-http-settings": {
|
"data-source-http-settings": {
|
||||||
"access-help": "Ħęľp <1></1>",
|
"access-help": "Ħęľp <1></1>",
|
||||||
"access-help-details": "Åččęşş mőđę čőʼnŧřőľş ĥőŵ řęqūęşŧş ŧő ŧĥę đäŧä şőūřčę ŵįľľ þę ĥäʼnđľęđ.<1> <1>Ŝęřvęř</1></1> şĥőūľđ þę ŧĥę přęƒęřřęđ ŵäy įƒ ʼnőŧĥįʼnģ ęľşę įş şŧäŧęđ.",
|
"access-help-details": "Åččęşş mőđę čőʼnŧřőľş ĥőŵ řęqūęşŧş ŧő ŧĥę đäŧä şőūřčę ŵįľľ þę ĥäʼnđľęđ.<1> <1>Ŝęřvęř</1></1> şĥőūľđ þę ŧĥę přęƒęřřęđ ŵäy įƒ ʼnőŧĥįʼnģ ęľşę įş şŧäŧęđ.",
|
||||||
|
Loading…
Reference in New Issue
Block a user