SharePanelInternally: Add render image inside drawer with customization (#97179)

This commit is contained in:
Juan Cabanas 2024-12-18 16:32:41 -03:00 committed by GitHub
parent 62b326a08c
commit 2c3c06e00f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 450 additions and 65 deletions

View File

@ -697,6 +697,26 @@ export const versionedPages = {
copyUrlButton: {
'11.3.0': 'data-testid share internally copy url button',
},
SharePanel: {
preview: {
'11.5.0': 'data-testid share panel internally image generation preview',
},
widthInput: {
'11.5.0': 'data-testid share panel internally width input',
},
heightInput: {
'11.5.0': 'data-testid share panel internally height input',
},
scaleFactorInput: {
'11.5.0': 'data-testid share panel internally scale factor input',
},
generateImageButton: {
'11.5.0': 'data-testid share panel internally generate image button',
},
downloadImageButton: {
'11.5.0': 'data-testid share panel internally download image button',
},
},
},
ShareExternally: {
container: {

View File

@ -19,7 +19,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { getMessageFromError } from 'app/core/utils/errors';
import { getCreateAlertInMenuAvailability } from 'app/features/alerting/unified/utils/access-control';
import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form';
import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { InspectTab } from 'app/features/inspector/types';
import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
@ -90,6 +90,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
iconClassName: 'link',
shortcut: 'p u',
onClick: () => {
DashboardInteractions.sharingCategoryClicked({
item: shareDashboardType.link,
shareResource: getTrackingSource(panel?.getRef()),
});
const drawer = new ShareDrawer({
shareView: shareDashboardType.link,
panelRef: panel.getRef(),
@ -103,6 +108,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
iconClassName: 'arrow',
shortcut: 'p e',
onClick: () => {
DashboardInteractions.sharingCategoryClicked({
item: shareDashboardType.embed,
shareResource: getTrackingSource(panel.getRef()),
});
const drawer = new ShareDrawer({
shareView: shareDashboardType.embed,
panelRef: panel.getRef(),
@ -122,6 +132,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
iconClassName: 'camera',
shortcut: 'p s',
onClick: () => {
DashboardInteractions.sharingCategoryClicked({
item: shareDashboardType.snapshot,
shareResource: getTrackingSource(panel.getRef()),
});
const drawer = new ShareDrawer({
shareView: shareDashboardType.snapshot,
panelRef: panel.getRef(),

View File

@ -1,7 +1,7 @@
import { dateTime } from '@grafana/data';
import { dateTime, UrlQueryMap } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, SceneObjectRef, VizPanel, sceneGraph } from '@grafana/scenes';
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
import { TimeZone } from '@grafana/schema';
import { Alert, ClipboardButton, Field, FieldSet, Icon, Input, Switch } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
@ -14,6 +14,7 @@ import { getDashboardUrl } from '../utils/urlBuilders';
import { getDashboardSceneFor } from '../utils/utils';
import { SceneShareTabState, ShareView } from './types';
export interface ShareLinkTabState extends SceneShareTabState, ShareOptions {
panelRef?: SceneObjectRef<VizPanel>;
}
@ -55,7 +56,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
this.onThemeChange = this.onThemeChange.bind(this);
}
async buildUrl() {
buildUrl = async (queryOptions?: UrlQueryMap) => {
this.setState({ isBuildUrlLoading: true });
const { panelRef, useLockedTime: useAbsoluteTimeRange, useShortUrl, selectedTheme } = this.state;
const dashboard = getDashboardSceneFor(this);
@ -83,7 +84,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
const imageUrl = getDashboardUrl({
uid: dashboard.state.uid,
currentQueryParams: location.search,
updateQuery: { ...urlParamsUpdate, panelId: panel?.state.key },
updateQuery: { ...urlParamsUpdate, ...queryOptions, panelId: panel?.state.key },
absolute: true,
soloRoute: true,
render: true,
@ -91,7 +92,7 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
});
this.setState({ shareUrl, imageUrl, isBuildUrlLoading: false });
}
};
public getTabLabel() {
return t('share-modal.tab-title.link', 'Link');

View File

@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config, setPluginImportUtils } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
import { userEvent } from '../../../../../test/test-utils';
import { DashboardScene } from '../../scene/DashboardScene';
import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager';
import { activateFullSceneTree } from '../../utils/test-utils';
import { SharePanelInternally } from './SharePanelInternally';
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel;
describe('SharePanelInternally', () => {
it('should disable all image generation inputs when renderer is not available', async () => {
config.rendererAvailable = false;
buildAndRenderScenario();
expect(await screen.findByTestId(selector.preview)).toBeInTheDocument();
[
selector.widthInput,
selector.heightInput,
selector.scaleFactorInput,
selector.generateImageButton,
selector.downloadImageButton,
].forEach((selector) => {
expect(screen.getByTestId(selector)).toBeDisabled();
});
});
it('should enable all image generation inputs when renderer is available', async () => {
config.rendererAvailable = true;
buildAndRenderScenario();
expect(await screen.findByTestId(selector.preview)).toBeInTheDocument();
[selector.widthInput, selector.heightInput, selector.scaleFactorInput].forEach((selector) => {
expect(screen.getByTestId(selector)).toBeEnabled();
});
await userEvent.type(screen.getByTestId(selector.widthInput), '1000');
await userEvent.type(screen.getByTestId(selector.widthInput), '2000');
expect(screen.getByTestId(selector.generateImageButton)).toBeEnabled();
expect(screen.getByTestId(selector.downloadImageButton)).toBeDisabled();
});
});
function buildAndRenderScenario() {
const panel = new VizPanel({
title: 'Panel A',
pluginId: 'table',
key: 'panel-12',
});
const tab = new SharePanelInternally({ panelRef: panel.getRef() });
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
meta: {
canEdit: true,
},
$timeRange: new SceneTimeRange({}),
body: DefaultGridLayoutManager.fromVizPanels([panel]),
overlay: tab,
});
activateFullSceneTree(scene);
render(<tab.Component model={tab} />);
return tab;
}

View File

@ -3,13 +3,15 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneComponentProps } from '@grafana/scenes';
import { Alert, ClipboardButton, Divider, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui';
import { Alert, ClipboardButton, Divider, Stack, Text, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { getDashboardSceneFor } from '../../utils/utils';
import ShareInternallyConfiguration from '../ShareInternallyConfiguration';
import { ShareLinkTab, ShareLinkTabState } from '../ShareLinkTab';
import { SharePanelPreview } from './SharePanelPreview';
export class SharePanelInternally extends ShareLinkTab {
static Component = SharePanelInternallyRenderer;
@ -24,56 +26,44 @@ export class SharePanelInternally extends ShareLinkTab {
function SharePanelInternallyRenderer({ model }: SceneComponentProps<SharePanelInternally>) {
const styles = useStyles2(getStyles);
const { useLockedTime, useShortUrl, selectedTheme, isBuildUrlLoading, imageUrl } = model.useState();
const { useLockedTime, useShortUrl, selectedTheme, isBuildUrlLoading, imageUrl, panelRef } = model.useState();
const panelTitle = panelRef?.resolve().state.title;
const dashboard = getDashboardSceneFor(model);
const isDashboardSaved = Boolean(dashboard.state.uid);
return (
<>
<div className={styles.configDescription}>
<Text variant="body">
<Trans i18nKey="link.share-panel.config-description">
Create a personalized, direct link to share your panel within your organization, with the following
customization settings:
</Trans>
</Text>
<div>
<Text variant="body">
<Trans i18nKey="link.share-panel.config-description">
Create a personalized, direct link to share your panel within your organization, with the following
customization settings:
</Trans>
</Text>
<div className={styles.configurationContainer}>
<ShareInternallyConfiguration
useLockedTime={useLockedTime}
onToggleLockedTime={model.onToggleLockedTime}
useShortUrl={useShortUrl}
onUrlShorten={model.onUrlShorten}
selectedTheme={selectedTheme}
onChangeTheme={model.onThemeChange}
isLoading={isBuildUrlLoading}
/>
<ClipboardButton
icon="link"
variant="primary"
fill="outline"
disabled={isBuildUrlLoading}
getText={model.getShareUrl}
onClipboardCopy={model.onCopy}
>
<Trans i18nKey="link.share.copy-link-button">Copy link</Trans>
</ClipboardButton>
</div>
<ShareInternallyConfiguration
useLockedTime={useLockedTime}
onToggleLockedTime={() => model.onToggleLockedTime()}
useShortUrl={useShortUrl}
onUrlShorten={() => model.onUrlShorten()}
selectedTheme={selectedTheme}
onChangeTheme={(t) => model.onThemeChange(t)}
isLoading={isBuildUrlLoading}
/>
<Divider spacing={1} />
<Divider spacing={2} />
<Stack gap={2} direction="column">
<div className={styles.buttonsContainer}>
<Stack gap={1} flex={1} direction={{ xs: 'column', sm: 'row' }}>
<ClipboardButton
icon="link"
variant="primary"
fill="outline"
disabled={isBuildUrlLoading}
getText={model.getShareUrl}
onClipboardCopy={model.onCopy}
>
<Trans i18nKey="link.share.copy-link-button">Copy link</Trans>
</ClipboardButton>
<LinkButton
href={imageUrl}
icon="external-link-alt"
target="_blank"
variant="secondary"
fill="solid"
disabled={!config.rendererAvailable || !isDashboardSaved}
>
<Trans i18nKey="link.share-panel.render-image">Render image</Trans>
</LinkButton>
</Stack>
</div>
{!isDashboardSaved && (
<Alert severity="info" title={t('share-modal.link.save-alert', 'Dashboard is not saved')} bottomSpacing={0}>
<Trans i18nKey="share-modal.link.save-dashboard">
@ -101,16 +91,20 @@ function SharePanelInternallyRenderer({ model }: SceneComponentProps<SharePanelI
</Trans>
</Alert>
)}
<SharePanelPreview
title={panelTitle || ''}
buildUrl={model.buildUrl}
imageUrl={imageUrl}
disabled={!isDashboardSaved}
theme={selectedTheme}
/>
</Stack>
</>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
configDescription: css({
marginBottom: theme.spacing(2),
}),
buttonsContainer: css({
configurationContainer: css({
marginTop: theme.spacing(2),
}),
});

View File

@ -0,0 +1,228 @@
import { css } from '@emotion/css';
import saveAs from 'file-saver';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useAsyncFn } from 'react-use';
import { lastValueFrom } from 'rxjs';
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
import { Alert, Button, Field, FieldSet, Icon, Input, LoadingBar, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { DashboardInteractions } from '../../utils/interactions';
type ImageSettingsForm = {
width: number;
height: number;
scaleFactor: number;
};
type Props = {
title: string;
buildUrl: (urlParams: UrlQueryMap) => void;
imageUrl: string;
disabled: boolean;
theme: string;
};
const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel;
export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }: Props) {
const styles = useStyles2(getStyles);
const {
handleSubmit,
register,
watch,
formState: { errors, isValid },
} = useForm<ImageSettingsForm>({
mode: 'onChange',
defaultValues: {
width: 1000,
height: 500,
scaleFactor: 1,
},
});
useEffect(() => {
buildUrl({ width: watch('width'), height: watch('height'), scale: watch('scaleFactor') });
}, [buildUrl, watch]);
const [{ loading, value: image, error }, renderImage] = useAsyncFn(async () => {
const { width, height, scaleFactor } = watch();
DashboardInteractions.generatePanelImageClicked({
width,
height,
scaleFactor,
theme,
shareResource: 'panel',
});
const response = await lastValueFrom(getBackendSrv().fetch<BlobPart>({ url: imageUrl, responseType: 'blob' }));
return new Blob([response.data], { type: 'image/png' });
}, [imageUrl, watch('width'), watch('height'), watch('scaleFactor'), theme]);
const onDownloadImageClick = () => {
DashboardInteractions.downloadPanelImageClicked({ shareResource: 'panel' });
saveAs(image!, `${title}.png`);
};
const onChange = () => {
buildUrl({ width: watch('width'), height: watch('height'), scale: watch('scaleFactor') });
};
return (
<div data-testid={selector.preview}>
<Stack gap={2} direction="column">
<Text element="h4">
<Trans i18nKey="share-panel-image.preview.title">Panel preview</Trans>
</Text>
<form onSubmit={handleSubmit(renderImage)}>
<FieldSet
disabled={!config.rendererAvailable}
label={
<Stack gap={1} alignItems="center">
<Text element="h5">
<Trans i18nKey="share-panel-image.settings.title">Image settings</Trans>
</Text>
<Tooltip
content={t(
'share-panel-image.settings.max-warning',
'Setting maximums are limited by the image renderer service'
)}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
}
>
<Stack gap={1} justifyContent="space-between" direction={{ xs: 'column', sm: 'row' }}>
<Field
label={t('share-panel-image.settings.width-label', 'Width')}
className={styles.imageConfigurationField}
required
invalid={!!errors.width}
error={errors.width?.message}
>
<Input
{...register('width', {
required: t('share-panel-image.settings.width-required', 'Width is required'),
min: {
value: 1,
message: t('share-panel-image.settings.width-min', 'Width must be equal or greater than 1'),
},
onChange: onChange,
})}
placeholder={t('share-panel-image.settings.width-placeholder', '1000')}
type="number"
suffix="px"
data-testid={selector.widthInput}
/>
</Field>
<Field
label={t('share-panel-image.settings.height-label', 'Height')}
className={styles.imageConfigurationField}
required
invalid={!!errors.height}
error={errors.height?.message}
>
<Input
{...register('height', {
required: t('share-panel-image.settings.height-required', 'Height is required'),
min: {
value: 1,
message: t('share-panel-image.settings.height-min', 'Height must be equal or greater than 1'),
},
onChange: onChange,
})}
placeholder={t('share-panel-image.settings.height-placeholder', '500')}
type="number"
suffix="px"
data-testid={selector.heightInput}
/>
</Field>
<Field
label={t('share-panel-image.settings.scale-factor-label', 'Scale factor')}
className={styles.imageConfigurationField}
required
invalid={!!errors.scaleFactor}
error={errors.scaleFactor?.message}
>
<Input
{...register('scaleFactor', {
required: t('share-panel-image.settings.scale-factor-required', 'Scale factor is required'),
min: {
value: 1,
message: t(
'share-panel-image.settings.scale-factor-min',
'Scale factor must be equal or greater than 1'
),
},
onChange: onChange,
})}
placeholder={t('share-panel-image.settings.scale-factor-placeholder', '1')}
type="number"
data-testid={selector.scaleFactorInput}
/>
</Field>
</Stack>
<Stack gap={1} direction={{ xs: 'column', sm: 'row' }}>
<Button
icon="gf-layout-simple"
variant="secondary"
fill="solid"
type="submit"
disabled={disabled || loading || !isValid}
data-testid={selector.generateImageButton}
>
<Trans i18nKey="link.share-panel.render-image">Generate image</Trans>
</Button>
<Button
onClick={onDownloadImageClick}
icon={'download-alt'}
variant="secondary"
disabled={!image || loading || disabled}
data-testid={selector.downloadImageButton}
>
<Trans i18nKey="link.share-panel.download-image">Download image</Trans>
</Button>
</Stack>
</FieldSet>
</form>
{loading && (
<div>
<LoadingBar width={128} />
<div className={styles.imageLoadingContainer}>
<Text variant="body">{title || ''}</Text>
</div>
</div>
)}
{image && !loading && <img src={URL.createObjectURL(image)} alt="panel-preview-img" className={styles.image} />}
{error && !loading && (
<Alert severity="error" title={t('link.share-panel.render-image-error', 'Failed to render panel image')}>
{isFetchError(error)
? error.statusText
: t('link.share-panel.render-image-error-description', 'An error occurred when generating the image')}
</Alert>
)}
</Stack>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
imageConfigurationField: css({
flex: 1,
}),
image: css({
maxWidth: '100%',
width: 'max-content',
}),
imageLoadingContainer: css({
maxWidth: '100%',
height: 362,
border: `1px solid ${theme.components.input.borderColor}`,
padding: theme.spacing(1),
}),
});

View File

@ -45,6 +45,12 @@ export const DashboardInteractions = {
embedSnippetCopy: (properties?: Record<string, unknown>) => {
reportDashboardInteraction('sharing_embed_copy_clicked', properties);
},
generatePanelImageClicked: (properties?: Record<string, unknown>) => {
reportDashboardInteraction('sharing_link_generate_image_clicked', properties);
},
downloadPanelImageClicked: (properties?: Record<string, unknown>) => {
reportDashboardInteraction('sharing_link_download_image_clicked', properties);
},
publishSnapshotClicked: (properties?: Record<string, unknown>) => {
reportDashboardInteraction('sharing_snapshot_publish_clicked', properties);
},

View File

@ -50,8 +50,8 @@ export function getDashboardUrl(options: DashboardUrlOptions) {
options.updateQuery = {
...options.updateQuery,
width: 1000,
height: 500,
width: options.updateQuery?.width || 1000,
height: options.updateQuery?.height || 500,
tz: options.timeZone,
};
}

View File

@ -1,8 +1,7 @@
import { FormEvent, useEffect, useState } from 'react';
import { useEffectOnce } from 'react-use';
import { RawTimeRange, TimeRange } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { Button, ClipboardButton, Field, Label, Modal, Stack, Switch, TextArea } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
@ -24,10 +23,6 @@ export function ShareEmbed({ panel, dashboard, range, onCancelClick, buildIframe
const [selectedTheme, setSelectedTheme] = useState('current');
const [iframeHtml, setIframeHtml] = useState('');
useEffectOnce(() => {
reportInteraction('grafana_dashboards_embed_share_viewed', { shareResource: getTrackingSource(panel) });
});
useEffect(() => {
const newIframeHtml = buildIframe(useCurrentTimeRange, dashboard.uid, selectedTheme, panel, range);
setIframeHtml(newIframeHtml);

View File

@ -1715,7 +1715,10 @@
},
"share-panel": {
"config-description": "Create a personalized, direct link to share your panel within your organization, with the following customization settings:",
"render-image": "Render image"
"download-image": "Download image",
"render-image": "Generate image",
"render-image-error": "Failed to render panel image",
"render-image-error-description": "An error occurred when generating the image"
}
},
"login": {
@ -3033,6 +3036,27 @@
"create-button": "Create library panel"
}
},
"share-panel-image": {
"preview": {
"title": "Panel preview"
},
"settings": {
"height-label": "Height",
"height-min": "Height must be equal or greater than 1",
"height-placeholder": "500",
"height-required": "Height is required",
"max-warning": "Setting maximums are limited by the image renderer service",
"scale-factor-label": "Scale factor",
"scale-factor-min": "Scale factor must be equal or greater than 1",
"scale-factor-placeholder": "1",
"scale-factor-required": "Scale factor is required",
"title": "Image settings",
"width-label": "Width",
"width-min": "Width must be equal or greater than 1",
"width-placeholder": "1000",
"width-required": "Width is required"
}
},
"share-playlist": {
"checkbox-description": "Panel heights will be adjusted to fit screen size",
"checkbox-label": "Autofit",

View File

@ -1715,7 +1715,10 @@
},
"share-panel": {
"config-description": "Cřęäŧę ä pęřşőʼnäľįžęđ, đįřęčŧ ľįʼnĸ ŧő şĥäřę yőūř päʼnęľ ŵįŧĥįʼn yőūř őřģäʼnįžäŧįőʼn, ŵįŧĥ ŧĥę ƒőľľőŵįʼnģ čūşŧőmįžäŧįőʼn şęŧŧįʼnģş:",
"render-image": "Ŗęʼnđęř įmäģę"
"download-image": "Đőŵʼnľőäđ įmäģę",
"render-image": "Ğęʼnęřäŧę įmäģę",
"render-image-error": "Fäįľęđ ŧő řęʼnđęř päʼnęľ įmäģę",
"render-image-error-description": "Åʼn ęřřőř őččūřřęđ ŵĥęʼn ģęʼnęřäŧįʼnģ ŧĥę įmäģę"
}
},
"login": {
@ -3033,6 +3036,27 @@
"create-button": "Cřęäŧę ľįþřäřy päʼnęľ"
}
},
"share-panel-image": {
"preview": {
"title": "Päʼnęľ přęvįęŵ"
},
"settings": {
"height-label": "Ħęįģĥŧ",
"height-min": "Ħęįģĥŧ mūşŧ þę ęqūäľ őř ģřęäŧęř ŧĥäʼn 1",
"height-placeholder": "500",
"height-required": "Ħęįģĥŧ įş řęqūįřęđ",
"max-warning": "Ŝęŧŧįʼnģ mäχįmūmş äřę ľįmįŧęđ þy ŧĥę įmäģę řęʼnđęřęř şęřvįčę",
"scale-factor-label": "Ŝčäľę ƒäčŧőř",
"scale-factor-min": "Ŝčäľę ƒäčŧőř mūşŧ þę ęqūäľ őř ģřęäŧęř ŧĥäʼn 1",
"scale-factor-placeholder": "1",
"scale-factor-required": "Ŝčäľę ƒäčŧőř įş řęqūįřęđ",
"title": "Ĩmäģę şęŧŧįʼnģş",
"width-label": "Ŵįđŧĥ",
"width-min": "Ŵįđŧĥ mūşŧ þę ęqūäľ őř ģřęäŧęř ŧĥäʼn 1",
"width-placeholder": "1000",
"width-required": "Ŵįđŧĥ įş řęqūįřęđ"
}
},
"share-playlist": {
"checkbox-description": "Päʼnęľ ĥęįģĥŧş ŵįľľ þę äđĵūşŧęđ ŧő ƒįŧ şčřęęʼn şįžę",
"checkbox-label": "Åūŧőƒįŧ",