mirror of
https://github.com/grafana/grafana.git
synced 2025-08-06 05:56:24 +08:00
Plugins: Custom links for plugin details page (#97186)
* Custom links with repository link, licence link, docs link and raise an issue link * run translation command * delete console log * delete console log * fix frontend tests * change UI with a new design * remove license, documentation, repository url calculation logic from grafana * remove unsused function from helpers * change repo icons and raise an issue icon * fix the build * remove logic for raiseAnIssueUrl * fix the build * fix lint * Delete Links title in the box of links --------- Co-authored-by: Timur Olzhabayev <timur.olzhabayev@grafana.com>
This commit is contained in:
@ -5501,7 +5501,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
|
||||
],
|
||||
"public/app/features/plugins/admin/components/PluginDetailsPanel.tsx:5381": [
|
||||
[0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
|
||||
[0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/plugins/admin/components/PluginDetailsSignature.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
|
@ -37,6 +37,8 @@ export async function getPluginDetails(id: string): Promise<CatalogPluginDetails
|
||||
iam: remote?.json?.iam,
|
||||
lastCommitDate: remote?.lastCommitDate,
|
||||
changelog: remote?.changelog || localChangelog,
|
||||
licenseUrl: remote?.licenseUrl,
|
||||
documentationUrl: remote?.documentationUrl,
|
||||
signatureType: local?.signatureType || (remote?.signatureType !== '' ? remote?.signatureType : undefined),
|
||||
signature: local?.signature,
|
||||
};
|
||||
|
@ -105,7 +105,6 @@ describe('PluginDetailsPanel', () => {
|
||||
it('should render report abuse section for non-core plugins', () => {
|
||||
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} />);
|
||||
expect(screen.getByText('Report a concern')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contact Grafana Labs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render report abuse section for core plugins', () => {
|
||||
@ -117,6 +116,6 @@ describe('PluginDetailsPanel', () => {
|
||||
it('should respect custom width prop', () => {
|
||||
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} width="300px" />);
|
||||
const panel = screen.getByTestId('plugin-details-panel');
|
||||
expect(panel).toHaveStyle({ maxWidth: '300px' });
|
||||
expect(panel).toHaveStyle({ width: '300px' });
|
||||
});
|
||||
});
|
||||
|
@ -1,8 +1,22 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { PageInfoItem } from '@grafana/runtime/src/components/PluginPage';
|
||||
import { Stack, Text, LinkButton, Box, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
LinkButton,
|
||||
Box,
|
||||
TextLink,
|
||||
CollapsableSection,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Modal,
|
||||
Button,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { formatDate } from 'app/core/internationalization/dates';
|
||||
|
||||
@ -16,73 +30,203 @@ type Props = {
|
||||
|
||||
export function PluginDetailsPanel(props: Props): React.ReactElement | null {
|
||||
const { pluginExtentionsInfo, plugin, width = '250px' } = props;
|
||||
const [reportAbuseModalOpen, setReportAbuseModalOpen] = useState(false);
|
||||
|
||||
const normalizeURL = (url: string | undefined) => url?.replace(/\/$/, '');
|
||||
|
||||
const customLinks = plugin.details?.links?.filter((link) => {
|
||||
const customLinksFiltered = ![plugin.url, plugin.details?.licenseUrl, plugin.details?.documentationUrl]
|
||||
.map(normalizeURL)
|
||||
.includes(normalizeURL(link.url));
|
||||
return customLinksFiltered;
|
||||
});
|
||||
const shouldRenderLinks = plugin.url || plugin.details?.licenseUrl || plugin.details?.documentationUrl;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={3} shrink={0} grow={0} maxWidth={width} data-testid="plugin-details-panel">
|
||||
<Box padding={2} borderColor="medium" borderStyle="solid">
|
||||
<Stack direction="column" gap={2}>
|
||||
{pluginExtentionsInfo.map((infoItem, index) => {
|
||||
return (
|
||||
<Stack key={index} wrap direction="column" gap={0.5}>
|
||||
<Text color="secondary">{infoItem.label + ':'}</Text>
|
||||
<div className={styles.pluginVersionDetails}>{infoItem.value}</div>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
{plugin.updatedAt && (
|
||||
<Stack direction="column" gap={0.5}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="plugins.details.labels.updatedAt">Last updated:</Trans>
|
||||
</Text>{' '}
|
||||
<Text>{formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{plugin?.details?.lastCommitDate && (
|
||||
<Stack direction="column" gap={0.5}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="plugins.details.labels.lastCommitDate">Last commit date:</Trans>
|
||||
</Text>{' '}
|
||||
<Text>
|
||||
{formatDate(new Date(plugin.details.lastCommitDate), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
const onClickReportConcern = (pluginId: string) => {
|
||||
setReportAbuseModalOpen(true);
|
||||
reportInteraction('plugin_detail_report_concern', {
|
||||
plugin_id: pluginId,
|
||||
});
|
||||
};
|
||||
|
||||
{plugin?.details?.links && plugin.details?.links?.length > 0 && (
|
||||
return (
|
||||
<>
|
||||
<Stack direction="column" gap={3} shrink={0} grow={0} width={width} data-testid="plugin-details-panel">
|
||||
<Box padding={2} borderColor="medium" borderStyle="solid">
|
||||
<Stack direction="column" gap={2}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="plugins.details.labels.links">Links </Trans>
|
||||
</Text>
|
||||
{plugin.details.links.map((link, index) => (
|
||||
<TextLink key={index} href={link.url} external>
|
||||
{link.name}
|
||||
</TextLink>
|
||||
))}
|
||||
{pluginExtentionsInfo.map((infoItem, index) => {
|
||||
return (
|
||||
<Stack key={index} wrap direction="column" gap={0.5}>
|
||||
<Text color="secondary">{infoItem.label + ':'}</Text>
|
||||
<div className={styles.pluginVersionDetails}>{infoItem.value}</div>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
{plugin.updatedAt && (
|
||||
<Stack direction="column" gap={0.5}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="plugins.details.labels.updatedAt">Last updated:</Trans>
|
||||
</Text>{' '}
|
||||
<Text>
|
||||
{formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{plugin?.details?.lastCommitDate && (
|
||||
<Stack direction="column" gap={0.5}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="plugins.details.labels.lastCommitDate">Last commit date:</Trans>
|
||||
</Text>{' '}
|
||||
<Text>
|
||||
{formatDate(new Date(plugin.details.lastCommitDate), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!plugin?.isCore && (
|
||||
<Box padding={2} borderColor="medium" borderStyle="solid">
|
||||
<Stack direction="column">
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="plugins.details.labels.reportAbuse">Report a concern </Trans>
|
||||
{shouldRenderLinks && (
|
||||
<>
|
||||
<Box padding={2} borderColor="medium" borderStyle="solid">
|
||||
<Stack direction="column" gap={2}>
|
||||
{plugin.url && (
|
||||
<LinkButton href={plugin.url} variant="secondary" fill="solid" icon="code-branch" target="_blank">
|
||||
<Trans i18nKey="plugins.details.labels.repository">Repository</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
{plugin.raiseAnIssueUrl && (
|
||||
<LinkButton href={plugin.raiseAnIssueUrl} variant="secondary" fill="solid" icon="bug" target="_blank">
|
||||
<Trans i18nKey="plugins.details.labels.raiseAnIssue">Raise an issue</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
{plugin.details?.licenseUrl && (
|
||||
<LinkButton
|
||||
href={plugin.details?.licenseUrl}
|
||||
variant="secondary"
|
||||
fill="solid"
|
||||
icon={'document-info'}
|
||||
target="_blank"
|
||||
>
|
||||
<Trans i18nKey="plugins.details.labels.license">License</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
{plugin.details?.documentationUrl && (
|
||||
<LinkButton
|
||||
href={plugin.details?.documentationUrl}
|
||||
variant="secondary"
|
||||
fill="solid"
|
||||
icon={'list-ui-alt'}
|
||||
target="_blank"
|
||||
>
|
||||
<Trans i18nKey="plugins.details.labels.documentation">Documentation</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{customLinks && customLinks?.length > 0 && (
|
||||
<Box padding={2} borderColor="medium" borderStyle="solid">
|
||||
<CollapsableSection
|
||||
isOpen={true}
|
||||
label={
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Text color="secondary" variant="body">
|
||||
<Trans i18nKey="plugins.details.labels.customLinks">Custom links </Trans>
|
||||
</Text>
|
||||
<Tooltip
|
||||
content={
|
||||
<Trans i18nKey="plugins.details.labels.customLinksTooltip">
|
||||
These links are provided by the plugin developer to offer additional, developer-specific
|
||||
resources and information
|
||||
</Trans>
|
||||
}
|
||||
placement="right-end"
|
||||
>
|
||||
<Icon name="info-circle" size="xs" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
{customLinks.map((link, index) => (
|
||||
<TextLink key={index} href={link.url} external>
|
||||
{link.name}
|
||||
</TextLink>
|
||||
))}
|
||||
</Stack>
|
||||
</CollapsableSection>
|
||||
</Box>
|
||||
)}
|
||||
{!plugin?.isCore && (
|
||||
<Box padding={2} borderColor="medium" borderStyle="solid">
|
||||
<CollapsableSection
|
||||
headerDataTestId="reportConcern"
|
||||
isOpen={false}
|
||||
label={
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Text color="secondary" variant="body">
|
||||
<Trans i18nKey="plugins.details.labels.reportAbuse">Report a concern </Trans>
|
||||
</Text>
|
||||
<Tooltip
|
||||
content={
|
||||
<Trans i18nKey="plugins.details.labels.reportAbuseTooltip">
|
||||
Report issues related to malicious or harmful plugins directly to Grafana Labs.
|
||||
</Trans>
|
||||
}
|
||||
placement="right-end"
|
||||
>
|
||||
<Icon name="info-circle" size="xs" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Stack direction="column">
|
||||
<Button variant="secondary" fill="solid" icon="bell" onClick={() => onClickReportConcern(plugin.id)}>
|
||||
<Trans i18nKey="plugins.details.labels.contactGrafanaLabs">Contact Grafana Labs</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</CollapsableSection>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
{reportAbuseModalOpen && (
|
||||
<Modal
|
||||
title={<Trans i18nKey="plugins.details.modal.title">Report a plugin concern</Trans>}
|
||||
isOpen
|
||||
onDismiss={() => setReportAbuseModalOpen(false)}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
<Text>
|
||||
<Trans i18nKey="plugins.details.modal.description">
|
||||
This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email
|
||||
us at:{' '}
|
||||
</Trans>
|
||||
<TextLink href="mailto:integrations+report-plugin@grafana.com">integrations@grafana.com</TextLink>
|
||||
</Text>
|
||||
<Text>
|
||||
<Trans i18nKey="plugins.details.modal.node">
|
||||
Note: For general plugin issues like bugs or feature requests, please contact the plugin author using
|
||||
the provided links.{' '}
|
||||
</Trans>
|
||||
</Text>
|
||||
<LinkButton href="mailto:integrations@grafana.com" variant="secondary" fill="solid">
|
||||
<Trans i18nKey="plugins.details.labels.contactGrafanaLabs">Contact Grafana Labs</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Modal.ButtonRow>
|
||||
<Button variant="secondary" fill="outline" onClick={() => setReportAbuseModalOpen(false)}>
|
||||
<Trans i18nKey="plugins.details.modal.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Button icon="copy" onClick={() => navigator.clipboard.writeText('integrations@grafana.com')}>
|
||||
<Trans i18nKey="plugins.details.modal.copyEmail">Copy email address</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -217,6 +217,7 @@ describe('Plugins/Helpers', () => {
|
||||
updatedAt: '2021-05-18T14:53:01.000Z',
|
||||
isFullyInstalled: false,
|
||||
angularDetected: false,
|
||||
url: 'https://github.com/alexanderzobnin/grafana-zabbix',
|
||||
});
|
||||
});
|
||||
|
||||
@ -354,6 +355,7 @@ describe('Plugins/Helpers', () => {
|
||||
installedVersion: '4.2.2',
|
||||
isFullyInstalled: true,
|
||||
angularDetected: false,
|
||||
url: 'https://github.com/alexanderzobnin/grafana-zabbix',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -121,6 +121,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
|
||||
signatureType,
|
||||
versionSignatureType,
|
||||
versionSignedByOrgName,
|
||||
url,
|
||||
raiseAnIssueUrl,
|
||||
} = plugin;
|
||||
|
||||
const isDisabled = !!error || isDisabledSecretsPlugin(typeCode);
|
||||
@ -158,6 +160,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
|
||||
angularDetected,
|
||||
isFullyInstalled: isDisabled,
|
||||
latestVersion: plugin.version,
|
||||
url,
|
||||
raiseAnIssueUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@ -174,6 +178,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
|
||||
hasUpdate,
|
||||
accessControl,
|
||||
angularDetected,
|
||||
raiseAnIssueUrl,
|
||||
} = plugin;
|
||||
|
||||
const isDisabled = !!error || isDisabledSecretsPlugin(type);
|
||||
@ -208,6 +213,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
|
||||
isFullyInstalled: true,
|
||||
iam: plugin.iam,
|
||||
latestVersion: plugin.latestVersion,
|
||||
raiseAnIssueUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@ -271,6 +277,8 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e
|
||||
isFullyInstalled: Boolean(local) || isDisabled,
|
||||
iam: local?.iam,
|
||||
latestVersion: local?.latestVersion || remote?.version || '',
|
||||
url: remote?.url || '',
|
||||
raiseAnIssueUrl: remote?.raiseAnIssueUrl || local?.raiseAnIssueUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -64,6 +64,8 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
|
||||
isUpdatingFromInstance?: boolean;
|
||||
iam?: IdentityAccessManagement;
|
||||
isProvisioned?: boolean;
|
||||
url?: string;
|
||||
raiseAnIssueUrl?: string;
|
||||
}
|
||||
|
||||
export interface CatalogPluginDetails {
|
||||
@ -79,6 +81,8 @@ export interface CatalogPluginDetails {
|
||||
iam?: IdentityAccessManagement;
|
||||
changelog?: string;
|
||||
lastCommitDate?: string;
|
||||
licenseUrl?: string;
|
||||
documentationUrl?: string;
|
||||
signatureType?: PluginSignatureType;
|
||||
signature?: PluginSignatureStatus;
|
||||
}
|
||||
@ -143,6 +147,9 @@ export type RemotePlugin = {
|
||||
versionStatus: string;
|
||||
angularDetected?: boolean;
|
||||
lastCommitDate?: string;
|
||||
licenseUrl?: string;
|
||||
documentationUrl?: string;
|
||||
raiseAnIssueUrl?: string;
|
||||
};
|
||||
|
||||
// The available status codes on GCOM are available here:
|
||||
@ -190,6 +197,7 @@ export type LocalPlugin = WithAccessControlMetadata & {
|
||||
dependencies: PluginDependencies;
|
||||
angularDetected: boolean;
|
||||
iam?: IdentityAccessManagement;
|
||||
raiseAnIssueUrl?: string;
|
||||
};
|
||||
|
||||
interface IdentityAccessManagement {
|
||||
|
@ -2781,17 +2781,30 @@
|
||||
},
|
||||
"labels": {
|
||||
"contactGrafanaLabs": "Contact Grafana Labs",
|
||||
"customLinks": "Custom links ",
|
||||
"customLinksTooltip": "These links are provided by the plugin developer to offer additional, developer-specific resources and information",
|
||||
"dependencies": "Dependencies",
|
||||
"documentation": "Documentation",
|
||||
"downloads": "Downloads",
|
||||
"from": "From",
|
||||
"installedVersion": "Installed Version",
|
||||
"lastCommitDate": "Last commit date:",
|
||||
"latestVersion": "Latest Version",
|
||||
"links": "Links ",
|
||||
"license": "License",
|
||||
"raiseAnIssue": "Raise an issue",
|
||||
"reportAbuse": "Report a concern ",
|
||||
"reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.",
|
||||
"repository": "Repository",
|
||||
"signature": "Signature",
|
||||
"status": "Status",
|
||||
"updatedAt": "Last updated:"
|
||||
},
|
||||
"modal": {
|
||||
"cancel": "Cancel",
|
||||
"copyEmail": "Copy email address",
|
||||
"description": "This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email us at: ",
|
||||
"node": "Note: For general plugin issues like bugs or feature requests, please contact the plugin author using the provided links. ",
|
||||
"title": "Report a plugin concern"
|
||||
}
|
||||
},
|
||||
"empty-state": {
|
||||
|
@ -2781,17 +2781,30 @@
|
||||
},
|
||||
"labels": {
|
||||
"contactGrafanaLabs": "Cőʼnŧäčŧ Ğřäƒäʼnä Ŀäþş",
|
||||
"customLinks": "Cūşŧőm ľįʼnĸş ",
|
||||
"customLinksTooltip": "Ŧĥęşę ľįʼnĸş äřę přővįđęđ þy ŧĥę pľūģįʼn đęvęľőpęř ŧő őƒƒęř äđđįŧįőʼnäľ, đęvęľőpęř-şpęčįƒįč řęşőūřčęş äʼnđ įʼnƒőřmäŧįőʼn",
|
||||
"dependencies": "Đępęʼnđęʼnčįęş",
|
||||
"documentation": "Đőčūmęʼnŧäŧįőʼn",
|
||||
"downloads": "Đőŵʼnľőäđş",
|
||||
"from": "Fřőm",
|
||||
"installedVersion": "Ĩʼnşŧäľľęđ Vęřşįőʼn",
|
||||
"lastCommitDate": "Ŀäşŧ čőmmįŧ đäŧę:",
|
||||
"latestVersion": "Ŀäŧęşŧ Vęřşįőʼn",
|
||||
"links": "Ŀįʼnĸş ",
|
||||
"license": "Ŀįčęʼnşę",
|
||||
"raiseAnIssue": "Ŗäįşę äʼn įşşūę",
|
||||
"reportAbuse": "Ŗępőřŧ ä čőʼnčęřʼn ",
|
||||
"reportAbuseTooltip": "Ŗępőřŧ įşşūęş řęľäŧęđ ŧő mäľįčįőūş őř ĥäřmƒūľ pľūģįʼnş đįřęčŧľy ŧő Ğřäƒäʼnä Ŀäþş.",
|
||||
"repository": "Ŗępőşįŧőřy",
|
||||
"signature": "Ŝįģʼnäŧūřę",
|
||||
"status": "Ŝŧäŧūş",
|
||||
"updatedAt": "Ŀäşŧ ūpđäŧęđ:"
|
||||
},
|
||||
"modal": {
|
||||
"cancel": "Cäʼnčęľ",
|
||||
"copyEmail": "Cőpy ęmäįľ äđđřęşş",
|
||||
"description": "Ŧĥįş ƒęäŧūřę įş ƒőř řępőřŧįʼnģ mäľįčįőūş őř ĥäřmƒūľ þęĥävįőūř ŵįŧĥįʼn pľūģįʼnş. Főř pľūģįʼn čőʼnčęřʼnş, ęmäįľ ūş äŧ: ",
|
||||
"node": "Ńőŧę: Főř ģęʼnęřäľ pľūģįʼn įşşūęş ľįĸę þūģş őř ƒęäŧūřę řęqūęşŧş, pľęäşę čőʼnŧäčŧ ŧĥę pľūģįʼn äūŧĥőř ūşįʼnģ ŧĥę přővįđęđ ľįʼnĸş. ",
|
||||
"title": "Ŗępőřŧ ä pľūģįʼn čőʼnčęřʼn"
|
||||
}
|
||||
},
|
||||
"empty-state": {
|
||||
|
Reference in New Issue
Block a user