mirror of https://github.com/grafana/grafana
Resource picker/improve ux (#44402)
parent
58ee553634
commit
8a7b469679
@ -0,0 +1,129 @@ |
||||
import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; |
||||
import { css } from '@emotion/css'; |
||||
import { Field, FilterInput, Select, useStyles2 } from '@grafana/ui'; |
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; |
||||
|
||||
import { MediaType, ResourceFolderName } from '../types'; |
||||
import { FileElement, GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource'; |
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; |
||||
import { ResourceCards } from './ResourceCards'; |
||||
|
||||
const getFolders = (mediaType: MediaType) => { |
||||
if (mediaType === MediaType.Icon) { |
||||
return [ResourceFolderName.Icon, ResourceFolderName.IOT, ResourceFolderName.Marker]; |
||||
} else { |
||||
return [ResourceFolderName.BG]; |
||||
} |
||||
}; |
||||
|
||||
const getFolderIfExists = (folders: Array<SelectableValue<string>>, path: string) => { |
||||
return folders.find((folder) => path.startsWith(folder.value!)) ?? folders[0]; |
||||
}; |
||||
|
||||
export interface ResourceItem { |
||||
label: string; |
||||
value: string; // includes folder
|
||||
search: string; |
||||
imgUrl: string; |
||||
} |
||||
|
||||
interface Props { |
||||
value?: string; |
||||
mediaType: MediaType; |
||||
folderName: ResourceFolderName; |
||||
newValue: string; |
||||
setNewValue: Dispatch<SetStateAction<string>>; |
||||
} |
||||
|
||||
export const FolderPickerTab = (props: Props) => { |
||||
const { value, mediaType, folderName, newValue, setNewValue } = props; |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const folders = getFolders(mediaType).map((v) => ({ |
||||
label: v, |
||||
value: v, |
||||
})); |
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>(); |
||||
|
||||
const [currentFolder, setCurrentFolder] = useState<SelectableValue<string>>( |
||||
getFolderIfExists(folders, value?.length ? value : folderName) |
||||
); |
||||
const [directoryIndex, setDirectoryIndex] = useState<ResourceItem[]>([]); |
||||
const [filteredIndex, setFilteredIndex] = useState<ResourceItem[]>([]); |
||||
|
||||
const onChangeSearch = (query: string) => { |
||||
if (query) { |
||||
query = query.toLowerCase(); |
||||
setFilteredIndex(directoryIndex.filter((card) => card.search.includes(query))); |
||||
} else { |
||||
setFilteredIndex(directoryIndex); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
// we don't want to load everything before picking a folder
|
||||
const folder = currentFolder?.value; |
||||
if (folder) { |
||||
const filter = |
||||
mediaType === MediaType.Icon |
||||
? (item: FileElement) => item.name.endsWith('.svg') |
||||
: (item: FileElement) => item.name.endsWith('.png') || item.name.endsWith('.gif'); |
||||
|
||||
getDatasourceSrv() |
||||
.get('-- Grafana --') |
||||
.then((ds) => { |
||||
(ds as GrafanaDatasource).listFiles(folder).subscribe({ |
||||
next: (frame) => { |
||||
const cards: ResourceItem[] = []; |
||||
frame.forEach((item) => { |
||||
if (filter(item)) { |
||||
const idx = item.name.lastIndexOf('.'); |
||||
cards.push({ |
||||
value: `${folder}/${item.name}`, |
||||
label: item.name, |
||||
search: (idx ? item.name.substr(0, idx) : item.name).toLowerCase(), |
||||
imgUrl: `public/${folder}/${item.name}`, |
||||
}); |
||||
} |
||||
}); |
||||
setDirectoryIndex(cards); |
||||
setFilteredIndex(cards); |
||||
}, |
||||
}); |
||||
}); |
||||
} |
||||
}, [mediaType, currentFolder]); |
||||
|
||||
return ( |
||||
<> |
||||
<Field> |
||||
<Select options={folders} onChange={setCurrentFolder} value={currentFolder} /> |
||||
</Field> |
||||
<Field> |
||||
<FilterInput |
||||
value={searchQuery ?? ''} |
||||
placeholder="Search" |
||||
onChange={(v) => { |
||||
onChangeSearch(v); |
||||
setSearchQuery(v); |
||||
}} |
||||
/> |
||||
</Field> |
||||
{filteredIndex && ( |
||||
<div className={styles.cardsWrapper}> |
||||
<ResourceCards cards={filteredIndex} onChange={(v) => setNewValue(v)} value={newValue} /> |
||||
</div> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
cardsWrapper: css` |
||||
height: 30vh; |
||||
min-height: 50px; |
||||
margin-top: 5px; |
||||
max-width: 680px; |
||||
`,
|
||||
}); |
@ -1,224 +1,81 @@ |
||||
import React, { useEffect, useState } from 'react'; |
||||
import React, { createRef } from 'react'; |
||||
import { css } from '@emotion/css'; |
||||
import { Button, InlineField, InlineFieldRow, Input, Popover, PopoverController, useStyles2 } from '@grafana/ui'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import SVG from 'react-inlinesvg'; |
||||
import { Button, Select, FilterInput, useTheme2, stylesFactory, Field, Modal, Label, Input } from '@grafana/ui'; |
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; |
||||
|
||||
import { ResourceCards } from './ResourceCards'; |
||||
import { getPublicOrAbsoluteUrl } from '../resource'; |
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; |
||||
import { FileElement, GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource'; |
||||
import { ResourceFolderName } from '..'; |
||||
import { MediaType, ResourceFolderName } from '../types'; |
||||
import { closePopover } from '@grafana/ui/src/utils/closePopover'; |
||||
import { ResourcePickerPopover } from './ResourcePickerPopover'; |
||||
|
||||
interface Props { |
||||
value?: string; //img/icons/unicons/0-plus.svg
|
||||
src?: string; |
||||
name?: string; |
||||
placeholder?: string; |
||||
onChange: (value?: string) => void; |
||||
mediaType: 'icon' | 'image'; |
||||
onClear: (event: React.MouseEvent) => void; |
||||
mediaType: MediaType; |
||||
folderName: ResourceFolderName; |
||||
setOpen: (value: boolean) => void; |
||||
} |
||||
|
||||
export interface ResourceItem { |
||||
label: string; |
||||
value: string; // includes folder
|
||||
search: string; |
||||
imgUrl: string; |
||||
} |
||||
|
||||
const sourceOptions = [ |
||||
{ label: `Folder`, value: 'folder' }, |
||||
{ label: 'URL', value: 'url' }, |
||||
// { label: 'Upload', value: 'upload' }, TODO
|
||||
]; |
||||
|
||||
const getFolders = (mediaType: 'icon' | 'image') => { |
||||
if (mediaType === 'icon') { |
||||
return [ResourceFolderName.Icon, ResourceFolderName.IOT, ResourceFolderName.Marker]; |
||||
} else { |
||||
return [ResourceFolderName.BG]; |
||||
} |
||||
}; |
||||
|
||||
const getFolderIfExists = (folders: Array<SelectableValue<string>>, path: string) => { |
||||
return folders.find((folder) => path.startsWith(folder.value!)) ?? folders[0]; |
||||
}; |
||||
|
||||
export const ResourcePicker = (props: Props) => { |
||||
const { value, onChange, mediaType, folderName, setOpen } = props; |
||||
const folders = getFolders(mediaType).map((v) => ({ |
||||
label: v, |
||||
value: v, |
||||
})); |
||||
|
||||
const [currentFolder, setCurrentFolder] = useState<SelectableValue<string>>( |
||||
getFolderIfExists(folders, value?.length ? value : folderName) |
||||
); |
||||
const [directoryIndex, setDirectoryIndex] = useState<ResourceItem[]>([]); |
||||
const [filteredIndex, setFilteredIndex] = useState<ResourceItem[]>([]); |
||||
// select between existing icon folder, url, or upload
|
||||
const [source, setSource] = useState<SelectableValue<string>>(sourceOptions[0]); |
||||
// pass on new value to confirm button and to show in preview
|
||||
const [newValue, setNewValue] = useState<string>(value ?? ''); |
||||
const [searchQuery, setSearchQuery] = useState<string>(); |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
const { value, src, name, placeholder, onChange, onClear, mediaType, folderName } = props; |
||||
|
||||
useEffect(() => { |
||||
// we don't want to load everything before picking a folder
|
||||
const folder = currentFolder?.value; |
||||
if (folder) { |
||||
const filter = |
||||
mediaType === 'icon' |
||||
? (item: FileElement) => item.name.endsWith('.svg') |
||||
: (item: FileElement) => item.name.endsWith('.png') || item.name.endsWith('.gif'); |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
getDatasourceSrv() |
||||
.get('-- Grafana --') |
||||
.then((ds) => { |
||||
(ds as GrafanaDatasource).listFiles(folder).subscribe({ |
||||
next: (frame) => { |
||||
const cards: ResourceItem[] = []; |
||||
frame.forEach((item) => { |
||||
if (filter(item)) { |
||||
const idx = item.name.lastIndexOf('.'); |
||||
cards.push({ |
||||
value: `${folder}/${item.name}`, |
||||
label: item.name, |
||||
search: (idx ? item.name.substr(0, idx) : item.name).toLowerCase(), |
||||
imgUrl: `public/${folder}/${item.name}`, |
||||
}); |
||||
} |
||||
}); |
||||
setDirectoryIndex(cards); |
||||
setFilteredIndex(cards); |
||||
}, |
||||
}); |
||||
}); |
||||
} |
||||
}, [mediaType, currentFolder]); |
||||
|
||||
const onChangeSearch = (query: string) => { |
||||
if (query) { |
||||
query = query.toLowerCase(); |
||||
setFilteredIndex(directoryIndex.filter((card) => card.search.includes(query))); |
||||
} else { |
||||
setFilteredIndex(directoryIndex); |
||||
} |
||||
}; |
||||
|
||||
const imgSrc = getPublicOrAbsoluteUrl(newValue!); |
||||
|
||||
let shortName = newValue?.substring(newValue.lastIndexOf('/') + 1, newValue.lastIndexOf('.')); |
||||
if (shortName.length > 20) { |
||||
shortName = shortName.substring(0, 20) + '...'; |
||||
} |
||||
const pickerTriggerRef = createRef<any>(); |
||||
const popoverElement = ( |
||||
<ResourcePickerPopover onChange={onChange} value={value} mediaType={mediaType} folderName={folderName} /> |
||||
); |
||||
|
||||
return ( |
||||
<div> |
||||
<div className={styles.upper}> |
||||
<div className={styles.child}> |
||||
<Field label="Source"> |
||||
<Select menuShouldPortal={true} options={sourceOptions} onChange={setSource} value={source} /> |
||||
</Field> |
||||
{source?.value === 'folder' && ( |
||||
<> |
||||
<Field label="Folder"> |
||||
<Select menuShouldPortal={true} options={folders} onChange={setCurrentFolder} value={currentFolder} /> |
||||
</Field> |
||||
<Field> |
||||
<FilterInput |
||||
value={searchQuery ?? ''} |
||||
placeholder="Search" |
||||
onChange={(v) => { |
||||
onChangeSearch(v); |
||||
setSearchQuery(v); |
||||
}} |
||||
/> |
||||
</Field> |
||||
</> |
||||
)} |
||||
{source?.value === 'url' && ( |
||||
<Field label="URL"> |
||||
<Input onChange={(e) => setNewValue(e.currentTarget.value)} value={newValue} /> |
||||
</Field> |
||||
)} |
||||
</div> |
||||
<div className={styles.iconContainer}> |
||||
<Field label="Preview"> |
||||
<div className={styles.iconPreview}> |
||||
{mediaType === 'icon' && <SVG src={imgSrc} className={styles.img} />} |
||||
{mediaType === 'image' && newValue && <img src={imgSrc} className={styles.img} />} |
||||
<PopoverController content={popoverElement}> |
||||
{(showPopper, hidePopper, popperProps) => { |
||||
return ( |
||||
<> |
||||
{pickerTriggerRef.current && ( |
||||
<Popover |
||||
{...popperProps} |
||||
referenceElement={pickerTriggerRef.current} |
||||
onMouseEnter={showPopper} |
||||
onKeyDown={(event: any) => { |
||||
closePopover(event, hidePopper); |
||||
}} |
||||
/> |
||||
)} |
||||
|
||||
<div ref={pickerTriggerRef} onClick={showPopper}> |
||||
<InlineFieldRow className={styles.pointer}> |
||||
<InlineField label={null} grow> |
||||
<Input |
||||
value={name} |
||||
placeholder={placeholder} |
||||
readOnly={true} |
||||
prefix={src && <SVG src={src} className={styles.icon} />} |
||||
suffix={<Button icon="times" variant="secondary" fill="text" size="sm" onClick={onClear} />} |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
</div> |
||||
</Field> |
||||
<Label>{shortName}</Label> |
||||
</div> |
||||
</div> |
||||
{source?.value === 'folder' && filteredIndex && ( |
||||
<div className={styles.cardsWrapper}> |
||||
<ResourceCards cards={filteredIndex} onChange={(v) => setNewValue(v)} value={newValue} /> |
||||
</div> |
||||
)} |
||||
|
||||
<Modal.ButtonRow> |
||||
<Button variant="secondary" onClick={() => setOpen(false)}> |
||||
Cancel |
||||
</Button> |
||||
<Button variant={newValue && newValue !== value ? 'primary' : 'secondary'} onClick={() => onChange(newValue)}> |
||||
Select |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
{/* TODO: add file upload |
||||
{tabs[1].active && ( |
||||
<FileUpload |
||||
onFileUpload={({ currentTarget }) => console.log('file', currentTarget?.files && currentTarget.files[0])} |
||||
className={styles.tabContent} |
||||
/> |
||||
)} */} |
||||
</div> |
||||
</> |
||||
); |
||||
}} |
||||
</PopoverController> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => { |
||||
return { |
||||
cardsWrapper: css` |
||||
height: 30vh; |
||||
min-height: 50px; |
||||
margin-top: 5px; |
||||
max-width: 680px; |
||||
`,
|
||||
tabContent: css` |
||||
margin-top: 20px; |
||||
& > :nth-child(2) { |
||||
margin-top: 10px; |
||||
}, |
||||
`,
|
||||
iconPreview: css` |
||||
width: 95px; |
||||
height: 79px; |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
`,
|
||||
iconContainer: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
width: 40%; |
||||
align-items: center; |
||||
`,
|
||||
img: css` |
||||
width: 49px; |
||||
height: 49px; |
||||
fill: ${theme.colors.text.primary}; |
||||
`,
|
||||
child: css` |
||||
width: 60%; |
||||
`,
|
||||
upper: css` |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
`,
|
||||
}; |
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
pointer: css` |
||||
cursor: pointer; |
||||
input[readonly] { |
||||
cursor: pointer; |
||||
} |
||||
`,
|
||||
icon: css` |
||||
vertical-align: middle; |
||||
display: inline-block; |
||||
fill: currentColor; |
||||
max-width: 25px; |
||||
`,
|
||||
}); |
||||
|
@ -0,0 +1,147 @@ |
||||
import React, { createRef, useState } from 'react'; |
||||
import { css } from '@emotion/css'; |
||||
import { Button, ButtonGroup, useStyles2 } from '@grafana/ui'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { FocusScope } from '@react-aria/focus'; |
||||
import { useOverlay } from '@react-aria/overlays'; |
||||
|
||||
import { MediaType, PickerTabType, ResourceFolderName } from '../types'; |
||||
import { FolderPickerTab } from './FolderPickerTab'; |
||||
import { URLPickerTab } from './URLPickerTab'; |
||||
|
||||
interface Props { |
||||
value?: string; //img/icons/unicons/0-plus.svg
|
||||
onChange: (value?: string) => void; |
||||
mediaType: MediaType; |
||||
folderName: ResourceFolderName; |
||||
} |
||||
|
||||
export const ResourcePickerPopover = (props: Props) => { |
||||
const { value, onChange, mediaType, folderName } = props; |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const onClose = () => { |
||||
onChange(value); |
||||
}; |
||||
|
||||
const ref = createRef<HTMLElement>(); |
||||
const { overlayProps } = useOverlay({ onClose, isDismissable: true, isOpen: true }, ref); |
||||
|
||||
const [newValue, setNewValue] = useState<string>(value ?? ''); |
||||
const [activePicker, setActivePicker] = useState<PickerTabType>(PickerTabType.Folder); |
||||
|
||||
const getTabClassName = (tabName: PickerTabType) => { |
||||
return `${styles.resourcePickerPopoverTab} ${activePicker === tabName && styles.resourcePickerPopoverActiveTab}`; |
||||
}; |
||||
|
||||
const renderFolderPicker = () => ( |
||||
<FolderPickerTab |
||||
value={value} |
||||
mediaType={mediaType} |
||||
folderName={folderName} |
||||
newValue={newValue} |
||||
setNewValue={setNewValue} |
||||
/> |
||||
); |
||||
|
||||
const renderURLPicker = () => <URLPickerTab newValue={newValue} setNewValue={setNewValue} mediaType={mediaType} />; |
||||
|
||||
const renderPicker = () => { |
||||
switch (activePicker) { |
||||
case PickerTabType.Folder: |
||||
return renderFolderPicker(); |
||||
case PickerTabType.URL: |
||||
return renderURLPicker(); |
||||
default: |
||||
return renderFolderPicker(); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<FocusScope contain autoFocus restoreFocus> |
||||
<section ref={ref} {...overlayProps}> |
||||
<div className={styles.resourcePickerPopover}> |
||||
<div className={styles.resourcePickerPopoverTabs}> |
||||
<button |
||||
className={getTabClassName(PickerTabType.Folder)} |
||||
onClick={() => setActivePicker(PickerTabType.Folder)} |
||||
> |
||||
Folder |
||||
</button> |
||||
<button className={getTabClassName(PickerTabType.URL)} onClick={() => setActivePicker(PickerTabType.URL)}> |
||||
URL |
||||
</button> |
||||
</div> |
||||
<div className={styles.resourcePickerPopoverContent}> |
||||
{renderPicker()} |
||||
<ButtonGroup className={styles.buttonGroup}> |
||||
<Button className={styles.button} variant={'secondary'} onClick={() => onClose()}> |
||||
Cancel |
||||
</Button> |
||||
<Button |
||||
className={styles.button} |
||||
variant={newValue && newValue !== value ? 'primary' : 'secondary'} |
||||
onClick={() => onChange(newValue)} |
||||
> |
||||
Select |
||||
</Button> |
||||
</ButtonGroup> |
||||
</div> |
||||
</div> |
||||
</section> |
||||
</FocusScope> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
resourcePickerPopover: css` |
||||
border-radius: ${theme.shape.borderRadius()}; |
||||
box-shadow: ${theme.shadows.z3}; |
||||
background: ${theme.colors.background.primary}; |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
`,
|
||||
resourcePickerPopoverTab: css` |
||||
width: 50%; |
||||
text-align: center; |
||||
padding: ${theme.spacing(1, 0)}; |
||||
background: ${theme.colors.background.secondary}; |
||||
color: ${theme.colors.text.secondary}; |
||||
font-size: ${theme.typography.bodySmall.fontSize}; |
||||
cursor: pointer; |
||||
border: none; |
||||
|
||||
&:focus:not(:focus-visible) { |
||||
outline: none; |
||||
box-shadow: none; |
||||
} |
||||
|
||||
:focus-visible { |
||||
position: relative; |
||||
} |
||||
`,
|
||||
resourcePickerPopoverActiveTab: css` |
||||
color: ${theme.colors.text.primary}; |
||||
font-weight: ${theme.typography.fontWeightMedium}; |
||||
background: ${theme.colors.background.primary}; |
||||
`,
|
||||
resourcePickerPopoverContent: css` |
||||
width: 315px; |
||||
font-size: ${theme.typography.bodySmall.fontSize}; |
||||
min-height: 184px; |
||||
padding: ${theme.spacing(1)}; |
||||
display: flex; |
||||
flex-direction: column; |
||||
`,
|
||||
resourcePickerPopoverTabs: css` |
||||
display: flex; |
||||
width: 100%; |
||||
border-radius: ${theme.shape.borderRadius()} ${theme.shape.borderRadius()} 0 0; |
||||
`,
|
||||
buttonGroup: css` |
||||
align-self: center; |
||||
flex-direction: row; |
||||
`,
|
||||
button: css` |
||||
margin: 12px 20px 5px; |
||||
`,
|
||||
}); |
@ -0,0 +1,66 @@ |
||||
import React, { Dispatch, SetStateAction } from 'react'; |
||||
import { Field, Input, Label, useStyles2 } from '@grafana/ui'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { css } from '@emotion/css'; |
||||
import SVG from 'react-inlinesvg'; |
||||
|
||||
import { MediaType } from '../types'; |
||||
import { getPublicOrAbsoluteUrl } from '../resource'; |
||||
|
||||
interface Props { |
||||
newValue: string; |
||||
setNewValue: Dispatch<SetStateAction<string>>; |
||||
mediaType: MediaType; |
||||
} |
||||
|
||||
export const URLPickerTab = (props: Props) => { |
||||
const { newValue, setNewValue, mediaType } = props; |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const imgSrc = getPublicOrAbsoluteUrl(newValue!); |
||||
|
||||
let shortName = newValue?.substring(newValue.lastIndexOf('/') + 1, newValue.lastIndexOf('.')); |
||||
if (shortName.length > 20) { |
||||
shortName = shortName.substring(0, 20) + '...'; |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<Field> |
||||
<Input onChange={(e) => setNewValue(e.currentTarget.value)} value={newValue} /> |
||||
</Field> |
||||
<div className={styles.iconContainer}> |
||||
<Field label="Preview"> |
||||
<div className={styles.iconPreview}> |
||||
{mediaType === MediaType.Icon && <SVG src={imgSrc} className={styles.img} />} |
||||
{mediaType === MediaType.Image && newValue && <img src={imgSrc} className={styles.img} />} |
||||
</div> |
||||
</Field> |
||||
<Label>{shortName}</Label> |
||||
</div> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
iconContainer: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
width: 80%; |
||||
align-items: center; |
||||
align-self: center; |
||||
`,
|
||||
iconPreview: css` |
||||
width: 238px; |
||||
height: 198px; |
||||
border: 1px solid ${theme.colors.border.medium}; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
`,
|
||||
img: css` |
||||
width: 147px; |
||||
height: 147px; |
||||
fill: ${theme.colors.text.primary}; |
||||
`,
|
||||
}); |
Loading…
Reference in new issue