mirror of https://github.com/vizality/vizality
move and rewrite large portions of addons list components; move from addon-manager to from addon-manager to components module to make it more modular and accessible
parent
79f5c48b4c
commit
5975cf7a85
@ -1,233 +0,0 @@
|
||||
import { AddonCard } from '@vizality/components/addon';
|
||||
import { Spinner, DeferredRender } from '@vizality/components';
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { existsSync, lstatSync, readdirSync } from 'fs';
|
||||
import { joinClassNames } from '@vizality/util/dom';
|
||||
import { useForceUpdate } from '@vizality/hooks';
|
||||
import { toPlural } from '@vizality/util/string';
|
||||
import { getModule } from '@vizality/webpack';
|
||||
import { Events } from '@vizality/constants';
|
||||
import { Messages } from '@vizality/i18n';
|
||||
import { join, extname } from 'path';
|
||||
|
||||
import StickyBar from './parts/StickyBar';
|
||||
|
||||
export default memo(({ type, tab, search, display, limit, className }) => {
|
||||
const { getSetting, updateSetting } = vizality.api.settings._fluxProps('addon-manager');
|
||||
const [ currentTab, setCurrentTab ] = useState(tab || 'installed');
|
||||
const [ query, setQuery ] = useState(search || '');
|
||||
const [ displayType, setDisplayType ] = useState(display || getSetting('listDisplay', 'card'));
|
||||
const [ showPreviewImages, setShowPreviewImages ] = useState(getSetting('showPreviewImages', false));
|
||||
const [ resultsCount, setResultsCount ] = useState(null);
|
||||
const { colorStandard } = getModule('colorStandard');
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
vizality.manager[toPlural(type)].on(Events.VIZALITY_ADDON_UNINSTALL, forceUpdate);
|
||||
vizality.manager[toPlural(type)].on(Events.VIZALITY_ADDON_ENABLE, forceUpdate);
|
||||
vizality.manager[toPlural(type)].on(Events.VIZALITY_ADDON_DISABLE, forceUpdate);
|
||||
return () => {
|
||||
vizality.manager[toPlural(type)].removeListener(Events.VIZALITY_ADDON_UNINSTALL, forceUpdate);
|
||||
vizality.manager[toPlural(type)].removeListener(Events.VIZALITY_ADDON_ENABLE, forceUpdate);
|
||||
vizality.manager[toPlural(type)].removeListener(Events.VIZALITY_ADDON_DISABLE, forceUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const _checkForPreviewImages = (addonId) => {
|
||||
const addon = vizality.manager[toPlural(type)].get(addonId);
|
||||
const screenshotsDir = join(addon.path, 'screenshots');
|
||||
|
||||
const hasPreviewImages = existsSync(screenshotsDir) && lstatSync(screenshotsDir).isDirectory();
|
||||
|
||||
if (!hasPreviewImages) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const _getPreviewImages = (addonId) => {
|
||||
if (!_checkForPreviewImages(addonId)) return [];
|
||||
|
||||
const addon = vizality.manager[toPlural(type)].get(addonId);
|
||||
|
||||
const previewImages = [];
|
||||
const validExtensions = [ '.png', '.gif', '.jpg', '.jpeg', '.webp' ];
|
||||
readdirSync(join(addon.path, 'screenshots'))
|
||||
.filter(file => validExtensions.indexOf(extname(file).toLowerCase()) !== -1)
|
||||
.map(file => previewImages.push(`vz-${type}://${addonId}/screenshots/${file}`));
|
||||
|
||||
return previewImages;
|
||||
};
|
||||
|
||||
/*
|
||||
* Including these in this component so we can forceUpdate the switches.
|
||||
* There's probably a better way to do it.
|
||||
*/
|
||||
const _enableAll = async type => {
|
||||
await vizality.manager[toPlural(type)].enableAll();
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
const _disableAll = async type => {
|
||||
await vizality.manager[toPlural(type)].disableAll();
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
const _resetSearchOptions = () => {
|
||||
return setQuery('');
|
||||
};
|
||||
|
||||
const _handleShowPreviewImages = (bool) => {
|
||||
updateSetting('showPreviewImages', bool);
|
||||
return setShowPreviewImages(bool);
|
||||
};
|
||||
|
||||
const _handleDisplayChange = (display) => {
|
||||
updateSetting('listDisplay', display);
|
||||
return setDisplayType(display);
|
||||
};
|
||||
|
||||
const _handleQueryChange = (query) => {
|
||||
return setQuery(query);
|
||||
};
|
||||
|
||||
const _handleTabChange = (tab) => {
|
||||
return setCurrentTab(tab);
|
||||
};
|
||||
|
||||
const _sortItems = items => {
|
||||
if (query && query !== '') {
|
||||
const search = query.toLowerCase();
|
||||
items = items.filter(p =>
|
||||
p.manifest.name.toLowerCase().includes(search) ||
|
||||
p.manifest.author.name?.toLowerCase().includes(search) ||
|
||||
(typeof p.manifest.author === 'string' && p.manifest.author.toLowerCase().includes(search)) ||
|
||||
p.manifest.description.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return items.sort((a, b) => {
|
||||
const nameA = a.manifest.name.toLowerCase();
|
||||
const nameB = b.manifest.name.toLowerCase();
|
||||
|
||||
if (nameA < nameB) {
|
||||
return -1;
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const _getItems = () => {
|
||||
return _sortItems([ ...vizality.manager[toPlural(type)].values ]);
|
||||
};
|
||||
|
||||
const renderItem = item => {
|
||||
return (
|
||||
<AddonCard
|
||||
display={displayType}
|
||||
type={type}
|
||||
addonId={item.addonId}
|
||||
enabled={vizality.manager[toPlural(type)].isEnabled(item.addonId)}
|
||||
installed={vizality.manager[toPlural(type)].isInstalled(item.addonId)}
|
||||
settings={vizality.manager[toPlural(type)].hasSettings(item.addonId)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* The only purpose of this is to add filler addon items to correct the
|
||||
* last flex row of the list.
|
||||
*/
|
||||
const renderFillers = () => {
|
||||
const placeholders = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
placeholders.push(
|
||||
<div className='vz-addon-card vz-addon-card-filler' />
|
||||
);
|
||||
}
|
||||
return placeholders;
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
const items = _getItems();
|
||||
if (items.length !== resultsCount) setResultsCount(items.length);
|
||||
return (
|
||||
<div className='vz-addons-list-items'>
|
||||
{items.length === 0
|
||||
? <div className='vz-addons-list-empty'>
|
||||
<div className={getModule('emptyStateImage', 'emptyStateSubtext')?.emptyStateImage}/>
|
||||
<p>{Messages.GIFT_CONFIRMATION_HEADER_FAIL}</p>
|
||||
<p>{Messages.SEARCH_NO_RESULTS}</p>
|
||||
</div>
|
||||
: <>
|
||||
{!limit
|
||||
? items.map(item => renderItem(item))
|
||||
: items.slice(0, limit).map(item => renderItem(item))
|
||||
}
|
||||
{!limit
|
||||
? renderFillers()
|
||||
: null
|
||||
}
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<div className='vz-addons-list-search-results-text-wrapper'>
|
||||
<div className='vz-addons-list-search-results-text'>
|
||||
<span className='vz-addons-list-search-results-count'>{resultsCount}</span> {toPlural(type)} found{!query && limit && resultsCount > limit && `... Showing ${limit}.`} {query && query !== '' && <>
|
||||
matching "<span className='vz-addons-list-search-results-matched'>{query}</span>"{limit && resultsCount > limit && `... Showing ${limit}.`}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={joinClassNames('vz-addons-list', className, colorStandard)}
|
||||
vz-display={displayType}
|
||||
vz-previews={Boolean(showPreviewImages) && ''}
|
||||
vz-type={type}
|
||||
>
|
||||
<StickyBar
|
||||
type={type}
|
||||
query={query}
|
||||
tab={currentTab}
|
||||
display={displayType}
|
||||
handleTabChange={_handleTabChange}
|
||||
handleQueryChange={_handleQueryChange}
|
||||
handleDisplayChange={_handleDisplayChange}
|
||||
enableAll={_enableAll}
|
||||
disableAll={_disableAll}
|
||||
resetSearchOptions={_resetSearchOptions}
|
||||
getSetting={getSetting}
|
||||
updateSetting={updateSetting}
|
||||
showPreviewImages={showPreviewImages}
|
||||
handleShowPreviewImages={_handleShowPreviewImages}
|
||||
/>
|
||||
<DeferredRender
|
||||
idleTimeout={1000}
|
||||
fallback={
|
||||
<div className='vz-addons-list-inner' vz-loading=''>
|
||||
<Spinner className='vz-addons-list-spinner' />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='vz-addons-list-inner'>
|
||||
{renderHeader()}
|
||||
{renderBody()}
|
||||
</div>
|
||||
</DeferredRender>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
@ -1,107 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { ContextMenu, Icon } from '@vizality/components';
|
||||
import { Messages } from '@vizality/i18n';
|
||||
|
||||
export default memo(props => {
|
||||
const { onClose } = props;
|
||||
|
||||
const Stars = (count) => {
|
||||
return (
|
||||
<>
|
||||
{Array.from(Array(count), () => <Icon name='Star' size='18px' />)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu.Menu navId='vz-addons-list-sort-filter-menu' onClose={onClose}>
|
||||
<ContextMenu.Group label='Sort'>
|
||||
<ContextMenu.RadioItem
|
||||
id='sort-name'
|
||||
group='sort'
|
||||
label='Name'
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
/>
|
||||
<ContextMenu.RadioItem
|
||||
id='sort-top-rated'
|
||||
group='sort'
|
||||
label='Rating'
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
<ContextMenu.RadioItem
|
||||
id='sort-recently-added'
|
||||
group='sort'
|
||||
label='Published Date'
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
<ContextMenu.RadioItem
|
||||
id='sort-most-reviewed'
|
||||
group='sort'
|
||||
label='Review Count'
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
<ContextMenu.RadioItem
|
||||
id='sort-most-downloaded'
|
||||
group='sort'
|
||||
label='Installs'
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
</ContextMenu.Group>
|
||||
<ContextMenu.Group label='Filter'>
|
||||
<ContextMenu.CheckboxItem
|
||||
id='filter-enabled'
|
||||
label='Enabled'
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
/>
|
||||
<ContextMenu.CheckboxItem
|
||||
id='filter-disabled'
|
||||
label='Disabled'
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
/>
|
||||
<ContextMenu.CheckboxItem
|
||||
id='filter-five-star'
|
||||
label={() => Stars(5)}
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
<ContextMenu.CheckboxItem
|
||||
id='filter-four-star'
|
||||
label={() => Stars(4)}
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
<ContextMenu.CheckboxItem
|
||||
id='filter-three-star'
|
||||
label={() => Stars(3)}
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
<ContextMenu.CheckboxItem
|
||||
id='filter-two-star'
|
||||
label={() => Stars(2)}
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
<ContextMenu.CheckboxItem
|
||||
id='filter-one-star'
|
||||
label={() => Stars(1)}
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
</ContextMenu.Group>
|
||||
</ContextMenu.Menu>
|
||||
);
|
||||
});
|
@ -1,131 +0,0 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
|
||||
import { StickyWrapper, TabBar, Icon, SearchBar } from '@vizality/components';
|
||||
import { toTitleCase } from '@vizality/util/string';
|
||||
import { getModule } from '@vizality/webpack';
|
||||
import { Messages } from '@vizality/i18n';
|
||||
|
||||
import SortFilterMenu from './SortFilterMenu';
|
||||
import OverflowMenu from './OverflowMenu';
|
||||
import DisplayMenu from './DisplayMenu';
|
||||
import TagsMenu from './TagsMenu';
|
||||
|
||||
export default memo(props => {
|
||||
const { query, tab, display, handleTabChange, handleQueryChange, handleDisplayChange } = props;
|
||||
const [ sticky, setSticky ] = useState(null);
|
||||
const PopoutDispatcher = getModule('openPopout');
|
||||
|
||||
const formatDisplayIconName = display => {
|
||||
return `Layout${toTitleCase(display).replace(' ', '')}`;
|
||||
};
|
||||
|
||||
const _handleStickyChange = (status) => {
|
||||
setSticky(status);
|
||||
};
|
||||
|
||||
const popoutConfig = {
|
||||
animationType: 0,
|
||||
closeOnScroll: false,
|
||||
shadow: false,
|
||||
position: 'bottom'
|
||||
};
|
||||
|
||||
const renderSortFilterMenu = e => {
|
||||
PopoutDispatcher.openPopout(e.target, {
|
||||
'vz-popout': 'vz-addons-list-sort-filter-menu',
|
||||
render: ({ onClose }) => <SortFilterMenu onClose={onClose} {...props} />,
|
||||
...popoutConfig
|
||||
}, 'vz-addons-list-sort-filter-menu');
|
||||
};
|
||||
|
||||
const renderTagsMenu = e => {
|
||||
PopoutDispatcher.openPopout(e.target, {
|
||||
'vz-popout': 'vz-addons-list-tags-menu',
|
||||
render: ({ onClose }) => <TagsMenu onClose={onClose} {...props} />,
|
||||
...popoutConfig
|
||||
}, 'vz-addons-list-tags-menu');
|
||||
};
|
||||
|
||||
const renderDisplayMenu = e => {
|
||||
PopoutDispatcher.openPopout(e.target, {
|
||||
'vz-popout': 'vz-addons-list-display-menu',
|
||||
render: ({ onClose }) => <DisplayMenu onClose={onClose} handleDisplayChange={handleDisplayChange} {...props} />,
|
||||
...popoutConfig
|
||||
}, 'vz-addons-list-display-menu');
|
||||
};
|
||||
|
||||
const renderOverflowMenu = e => {
|
||||
PopoutDispatcher.openPopout(e.target, {
|
||||
'vz-popout': 'vz-addons-list-overflow-menu',
|
||||
render: ({ onClose }) => <OverflowMenu onClose={onClose} display={display} {...props} />,
|
||||
...popoutConfig
|
||||
}, 'vz-addons-list-overflow-menu');
|
||||
};
|
||||
|
||||
return (
|
||||
<StickyWrapper
|
||||
handleStickyChange={_handleStickyChange}
|
||||
wrapperClassName='vz-addons-list-sticky-bar-wrapper'
|
||||
className='vz-addons-list-sticky-bar'
|
||||
>
|
||||
<TabBar
|
||||
selectedItem={tab}
|
||||
onItemSelect={handleTabChange}
|
||||
type={TabBar.Types.TOP_PILL}
|
||||
>
|
||||
<TabBar.Item selectedItem={tab} id='installed'>
|
||||
{Messages.VIZALITY_INSTALLED}
|
||||
</TabBar.Item>
|
||||
<TabBar.Item selectedItem={tab} id='discover'>
|
||||
{Messages.DISCOVER}
|
||||
</TabBar.Item>
|
||||
<TabBar.Item selectedItem={tab} id='browse'>
|
||||
Browse
|
||||
</TabBar.Item>
|
||||
</TabBar>
|
||||
<div className='vz-addons-list-search-options'>
|
||||
<div className='vz-addons-list-search'>
|
||||
<SearchBar
|
||||
placeholder={Messages.SEARCH}
|
||||
query={query}
|
||||
onChange={handleQueryChange}
|
||||
onClear={() => handleQueryChange('')}
|
||||
/>
|
||||
</div>
|
||||
<div className='vz-addons-list-filter-button vz-addons-list-search-options-button'>
|
||||
<Icon
|
||||
tooltip={`Sort & Filter`}
|
||||
size='20'
|
||||
tooltipPosition={sticky === 'stuck' ? 'bottom' : 'top'}
|
||||
name='FilterAlt'
|
||||
onClick={renderSortFilterMenu}
|
||||
/>
|
||||
</div>
|
||||
<div className='vz-addons-list-tags-button vz-addons-list-search-options-button'>
|
||||
<Icon
|
||||
tooltip='Tags'
|
||||
name='StoreTag'
|
||||
tooltipPosition={sticky === 'stuck' ? 'bottom' : 'top'}
|
||||
onClick={renderTagsMenu}
|
||||
/>
|
||||
</div>
|
||||
<div className='vz-addons-list-display-button vz-addons-list-search-options-button'>
|
||||
<Icon
|
||||
tooltip='Display'
|
||||
tooltipPosition={sticky === 'stuck' ? 'bottom' : 'top'}
|
||||
name={formatDisplayIconName(display)}
|
||||
onClick={renderDisplayMenu}
|
||||
/>
|
||||
</div>
|
||||
<div className='vz-addons-list-more-button vz-addons-list-search-options-button'>
|
||||
<Icon
|
||||
tooltip={Messages.MORE}
|
||||
tooltipPosition={sticky === 'stuck' ? 'bottom' : 'top'}
|
||||
name='OverflowMenu'
|
||||
onClick={renderOverflowMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StickyWrapper>
|
||||
);
|
||||
});
|
@ -0,0 +1,35 @@
|
||||
@use '@vizality' as vz;
|
||||
|
||||
[vz-banners] {
|
||||
.vz-addon-card-banner {
|
||||
&-wrapper {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
&-inner {
|
||||
[vz-theme='light'] & {
|
||||
background-image: url('/assets/7b6ed225050df29a07cb5db712d35a73.svg');
|
||||
}
|
||||
background-image: url('/assets/d03d90cb6f12a7ea06274b278dfa4160.svg');
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--background-tertiary);
|
||||
padding-top: 31.25%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
&-image-wrapper {
|
||||
@include vz.size(100%);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
&-image {
|
||||
@include vz.size(100%);
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
@forward 'displays';
|
||||
@forward 'context-menu';
|
||||
@forward 'previews';
|
||||
@forward 'banners';
|
||||
|
@ -1,145 +0,0 @@
|
||||
@use '@vizality' as vz;
|
||||
|
||||
[vz-previews] {
|
||||
.vz-addon-card {
|
||||
&-previews-wrapper {
|
||||
// max-width: 100%; */
|
||||
// backface-visibility: hidden; */
|
||||
// position: relative; */
|
||||
// height: auto;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
&-previews-inner {
|
||||
height: 100%;
|
||||
&[vz-count='1'] {
|
||||
.pagination-20ouY7 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-previews-empty {
|
||||
background: var(--background-secondary-alt);
|
||||
border: 1px solid var(--background-secondary-alt);
|
||||
border-bottom: 1px solid var(--background-tertiary);
|
||||
padding-top: 42.55%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px 8px 0 0;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
z-index: 1;
|
||||
opacity: 1;
|
||||
transform: rotate(30deg);
|
||||
@at-root [vz-type='plugin'][vz-previews] .vz-addon-card .vz-addon-card-previews-empty {
|
||||
&::after {
|
||||
@include vz.mask(vz.icon('plugin'), var(--background-tertiary), 10%, space);
|
||||
}
|
||||
}
|
||||
@at-root [vz-type='theme'][vz-previews] .vz-addon-card .vz-addon-card-previews-empty {
|
||||
&::after {
|
||||
@include vz.mask(vz.icon('theme'), var(--background-tertiary), 10%, repeat center);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.outer-s4sY2_ {
|
||||
overflow: hidden;
|
||||
padding-top: 42.55% !important; // Cinemascope aspect ratio
|
||||
}
|
||||
.smallCarousel-2e0IQc {
|
||||
background: var(--background-secondary-alt);
|
||||
border: 1px solid var(--background-secondary-alt);
|
||||
border-bottom: 1px solid var(--background-tertiary);
|
||||
&,
|
||||
.smallCarouselItem-1rfKEJ {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
}
|
||||
.arrowHitboxPadding-2stwpJ {
|
||||
padding: 5px;
|
||||
background: var(--background-tertiary);
|
||||
margin: 5px;
|
||||
opacity: 0.7;
|
||||
border-radius: 50%;
|
||||
transition: opacity .2s, box-shadow .2s;
|
||||
svg {
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
box-shadow: var(--elevation-high);
|
||||
}
|
||||
}
|
||||
.smallCarouselImage-2Qvg9S {
|
||||
object-fit: cover;
|
||||
}
|
||||
.pagination-20ouY7 {
|
||||
@include vz.size(100%);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
.dots-3VxbPX {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
&-preview {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
&-img {
|
||||
@include vz.size(100%);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: 5px 5px 0 0;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
}
|
||||
&-preview-previous,
|
||||
&-preview-next {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -10px;
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -10px, 0);
|
||||
transition: transform .2s ease, opacity .2s ease;
|
||||
&-icon-wrapper {
|
||||
background: var(--background-floating);
|
||||
box-shadow: var(--elevation-high);
|
||||
border-radius: 50%;
|
||||
padding: 15px;
|
||||
opacity: .8;
|
||||
transition: opacity .2s ease-in-out;
|
||||
&:hover {
|
||||
color: var(--header-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-preview-next {
|
||||
left: unset;
|
||||
right: -10px;
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +1,33 @@
|
||||
@forward 'screenshots';
|
||||
|
||||
.vz-addon-listing {
|
||||
// &-banner-wrapper {
|
||||
// width: 100%;
|
||||
// position: relative;
|
||||
// top: 0;
|
||||
// left: 0;
|
||||
// height: 600px;
|
||||
// z-index: 9999;
|
||||
// background-image: url(https://discord.com/assets/d03d90c….svg);
|
||||
// background-image: url(https://wallpaperaccess.com/full/2461288.jpg);
|
||||
// background-repeat: no-repeat;
|
||||
// background-position: center;
|
||||
// background-size: cover;
|
||||
// -webkit-mask-image: linear-gradient(to top, transparent 0%, #000 70%);
|
||||
// }
|
||||
// &-top-card {
|
||||
// /* z-index: 8888888888; */
|
||||
// width: calc(100% - 80px);
|
||||
// background: var(--background-tertiary);
|
||||
// height: 300px;
|
||||
// margin: -200px 40px 0;
|
||||
// /* box-sizing: border-box; */
|
||||
// border-radius: 8px;
|
||||
// position: relative;
|
||||
// /* backdrop-filter: blur(10px); */
|
||||
// /* border: 1px solid var(--background-modifier-accent); */
|
||||
// margin-top: -150px;
|
||||
// z-index: 99999;
|
||||
// opacity: .9;
|
||||
// }
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import AddonsList from './List';
|
||||
|
||||
export default memo(props => (
|
||||
<AddonsList tab='browse' {...props} />
|
||||
));
|
@ -0,0 +1,7 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import AddonsList from './List';
|
||||
|
||||
export default memo(props => (
|
||||
<AddonsList tab='installed' {...props} />
|
||||
));
|
@ -0,0 +1,466 @@
|
||||
import { Spinner, DeferredRender, StickyWrapper, Icon, SearchBar, FilterInput, HelpMessage } from '@vizality/components';
|
||||
import React, { memo, useState, useEffect, useRef } from 'react';
|
||||
import { unstable_batchedUpdates } from 'react-dom';
|
||||
import { toPlural, toTitleCase } from '@vizality/util/string';
|
||||
import { joinClassNames } from '@vizality/util/dom';
|
||||
import { useForceUpdate, usePrevious, useFilter, useSticky } from '@vizality/hooks';
|
||||
import { getModule } from '@vizality/webpack';
|
||||
import { error } from '@vizality/util/logger';
|
||||
import { Events } from '@vizality/constants';
|
||||
import { Messages } from '@vizality/i18n';
|
||||
import Sticky from 'react-stickynode';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import SortFilterMenu from './menus/SortFilterMenu';
|
||||
import OverflowMenu from './menus/OverflowMenu';
|
||||
import DisplayMenu from './menus/DisplayMenu';
|
||||
import TagsMenu from './menus/TagsMenu';
|
||||
|
||||
import AddonCard from '../AddonCard';
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
const _labels = [ 'Component', 'AddonsList' ];
|
||||
const _error = (...message) => error({ labels: _labels, message });
|
||||
|
||||
/**
|
||||
* Renders addon card fillers. The only purpose of this is to add filler addon items to
|
||||
* correct the item widths of the last flex row of the list.
|
||||
* @private
|
||||
* @component
|
||||
* @returns {React.MemoExoticComponent<function(): React.ReactElement>}
|
||||
*/
|
||||
const FillerAddonCards = memo(() => {
|
||||
const placeholders = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
placeholders.push(
|
||||
<div className='vz-addon-card vz-addon-card-filler' />
|
||||
);
|
||||
}
|
||||
return placeholders;
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders a search results header, which shows what type of addons are being displayed
|
||||
* as well as how many, given the current query and filter results.
|
||||
* @private
|
||||
* @component
|
||||
* @returns {React.MemoExoticComponent<function(): React.ReactElement>}
|
||||
*/
|
||||
const SearchResultsHeader = memo(({ query, resultsCount, limit, type, tab }) => {
|
||||
const { marginTop20 } = getModule('marginTop20');
|
||||
return (
|
||||
<>
|
||||
{tab === 'browse' && (
|
||||
<HelpMessage messageType={HelpMessage.Types.WARNING} className={joinClassNames('vz-addons-list-help-message', marginTop20)}>
|
||||
The download counts are shown for testing and demo purposes only and do not currently provide an accurate representation of the addon's download count.
|
||||
</HelpMessage>
|
||||
)}
|
||||
<div className='vz-addons-list-search-results-text-wrapper'>
|
||||
<div className='vz-addons-list-search-results-text'>
|
||||
<span className='vz-addons-list-search-results-count'>{resultsCount}</span> {toPlural(type)} found{!query && limit && resultsCount > limit && `... Showing ${limit}.`} {query && query !== '' && <>
|
||||
matching "<span className='vz-addons-list-search-results-matched'>{query}</span>"{limit && resultsCount > limit && `... Showing ${limit}.`}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders the content body, which consists of the addon cards.
|
||||
* @private
|
||||
* @component
|
||||
* @returns {React.MemoExoticComponent<function(): React.ReactElement>}
|
||||
*/
|
||||
const ContentBody = memo(({ community, display, showBanners, filteredResults, type, limit, resultsCount, handleResultsCount }) => {
|
||||
const { emptyStateImage } = getModule('emptyStateImage', 'emptyStateSubtext');
|
||||
|
||||
/**
|
||||
* If the filtered items count doesn't equal the current results count, update the value.
|
||||
*/
|
||||
if (filteredResults.length !== resultsCount) {
|
||||
handleResultsCount(filteredResults.length);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='vz-addons-list-items'>
|
||||
{filteredResults.length
|
||||
? (
|
||||
<>
|
||||
{filteredResults.slice(0, limit || filteredResults.length).map(item => (
|
||||
<AddonCard
|
||||
community={community}
|
||||
display={display}
|
||||
showBanner={showBanners}
|
||||
type={type}
|
||||
addonId={item.addonId}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<FillerAddonCards />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<div className='vz-addons-list-empty'>
|
||||
<div className={emptyStateImage} />
|
||||
<p>{Messages.GIFT_CONFIRMATION_HEADER_FAIL}</p>
|
||||
<p>{Messages.SEARCH_NO_RESULTS}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders the page stickybar, which contains the tabs, search bar, and various display,
|
||||
* sorting, and filtering options.
|
||||
* @private
|
||||
* @component
|
||||
* @returns {React.MemoExoticComponent<function(): React.ReactElement>}
|
||||
*/
|
||||
const StickyBar = memo(({ query, type, showBanners, resetSearchOptions, handleShowBanners, display, handleTabChange, handleClearQuery, handleQueryChange, handleDisplayChange }) => {
|
||||
const [ sticky, setSticky ] = useState(false);
|
||||
const headerRef = useRef(null);
|
||||
const PopoutDispatcher = getModule('openPopout');
|
||||
|
||||
/**
|
||||
* Formats a display type string into one matching its corresponding icon name.
|
||||
* @param {string} display Addon display type
|
||||
* @returns {string}
|
||||
*/
|
||||
const formatDisplayIconName = display => {
|
||||
return `Layout${toTitleCase(display).replace(' ', '')}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const header = headerRef?.current;
|
||||
const observer = new IntersectionObserver(
|
||||
([ element ]) => {
|
||||
/**
|
||||
* e is our target element -- the header;
|
||||
* other properties available include:
|
||||
* boundingClientRect
|
||||
* intersectionRatio
|
||||
* intersectionRect
|
||||
* rootBounds
|
||||
* target
|
||||
* time
|
||||
*/
|
||||
setSticky(element.isIntersecting < 1);
|
||||
},
|
||||
{ threshold: [ 1 ] }
|
||||
);
|
||||
|
||||
if (header) {
|
||||
observer.observe(header);
|
||||
}
|
||||
|
||||
// clean up the observer
|
||||
return (() => {
|
||||
observer.unobserve(header);
|
||||
});
|
||||
}, [ headerRef ]);
|
||||
|
||||
/**
|
||||
* Configuration for the popouts used in this component.
|
||||
*/
|
||||
const popoutConfig = {
|
||||
animationType: 0,
|
||||
closeOnScroll: false,
|
||||
shadow: false,
|
||||
position: 'bottom'
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the Sort and Filter context menu / popout.
|
||||
* @param {document#event:mousedown} evt Mousedown event
|
||||
* @returns {void}
|
||||
*/
|
||||
const renderSortFilterMenu = evt => {
|
||||
PopoutDispatcher.openPopout(evt.target, {
|
||||
'vz-popout': 'vz-addons-list-sort-filter-menu',
|
||||
render: ({ onClose }) => <SortFilterMenu onClose={onClose} />,
|
||||
...popoutConfig
|
||||
}, 'vz-addons-list-sort-filter-menu');
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the Tags context menu / popout.
|
||||
* @param {document#event:mousedown} evt Mousedown event
|
||||
* @returns {void}
|
||||
*/
|
||||
const renderTagsMenu = evt => {
|
||||
PopoutDispatcher.openPopout(evt.target, {
|
||||
'vz-popout': 'vz-addons-list-tags-menu',
|
||||
render: ({ onClose }) => <TagsMenu onClose={onClose} type={type} />,
|
||||
...popoutConfig
|
||||
}, 'vz-addons-list-tags-menu');
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the Display options context menu / popout.
|
||||
* @param {document#event:mousedown} evt Mousedown event
|
||||
* @returns {void}
|
||||
*/
|
||||
const renderDisplayMenu = evt => {
|
||||
PopoutDispatcher.openPopout(evt.target, {
|
||||
'vz-popout': 'vz-addons-list-display-menu',
|
||||
render: ({ onClose }) => (
|
||||
<DisplayMenu
|
||||
onClose={onClose}
|
||||
handleDisplayChange={handleDisplayChange}
|
||||
showBanners={showBanners}
|
||||
handleShowBanners={handleShowBanners}
|
||||
display={display}
|
||||
/>
|
||||
),
|
||||
...popoutConfig
|
||||
}, 'vz-addons-list-display-menu');
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the More context menu / popout.
|
||||
* @param {document#event:mousedown} evt Mousedown event
|
||||
* @returns {void}
|
||||
*/
|
||||
const renderOverflowMenu = evt => {
|
||||
PopoutDispatcher.openPopout(evt.target, {
|
||||
'vz-popout': 'vz-addons-list-overflow-menu',
|
||||
render: ({ onClose }) => <OverflowMenu onClose={onClose} type={type} resetSearchOptions={resetSearchOptions} />,
|
||||
...popoutConfig
|
||||
}, 'vz-addons-list-overflow-menu');
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
ref={headerRef}
|
||||
className={joinClassNames('vz-sticky-wrapper', 'vz-addons-list-sticky-bar-wrapper', { stuck: sticky })}
|
||||
>
|
||||
<div className={joinClassNames('vz-sticky', 'vz-addons-list-sticky-bar')}>
|
||||
<div type='top-28JiJ-'>
|
||||
<div onClick={() => handleTabChange('installed')} vz-tab='installed'>
|
||||
{Messages.VIZALITY_INSTALLED}
|
||||
</div>
|
||||
<div onClick={() => handleTabChange('discover')} vz-tab='discover'>
|
||||
{Messages.DISCOVER}
|
||||
</div>
|
||||
<div onClick={() => handleTabChange('browse')} vz-tab='browse'>
|
||||
Browse
|
||||
</div>
|
||||
</div>
|
||||
<div className='vz-addons-list-search-options'>
|
||||
<div className='vz-addons-list-search'>
|
||||
<FilterInput
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
fuzzy
|
||||
/>
|
||||
</div>
|
||||
<div className='vz-addons-list-filter-button vz-addons-list-search-options-button'>
|
||||
<Icon
|
||||
tooltip={`Sort & Filter`}
|
||||
size='20'
|
||||
tooltipPosition={sticky ? 'bottom' : 'top'}
|
||||
name='FilterAlt'
|
||||
onClick={renderSortFilterMenu}
|
||||
/>
|
||||
</div>
|
||||
<div className='vz-addons-list-tags-button vz-addons-list-search-options-button'>
|
||||
<Icon
|
||||
tooltip='Tags'
|
||||
name='StoreTag'
|
||||
tooltipPosition={sticky ? 'bottom' : 'top'}
|
||||
onClick={renderTagsMenu}
|
||||
/>
|
||||
</div>
|
||||
<div className='vz-addons-list-display-button vz-addons-list-search-options-button'>
|
||||
<Icon
|
||||
tooltip='Display'
|
||||
tooltipPosition={sticky ? 'bottom' : 'top'}
|
||||
name={formatDisplayIconName(display)}
|
||||
onClick={renderDisplayMenu}
|
||||
/>
|
||||
</div>
|
||||
<div className='vz-addons-list-more-button vz-addons-list-search-options-button'>
|
||||
<Icon
|
||||
tooltip={Messages.MORE}
|
||||
tooltipPosition={sticky ? 'bottom' : 'top'}
|
||||
name='OverflowMenu'
|
||||
onClick={renderOverflowMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders the addons list.
|
||||
* @returns {React.MemoExoticComponent<function(): React.ReactElement>}
|
||||
*/
|
||||
export default memo(({ source, type, tab, search, display, limit, showBanners, className, showOptionsBar = true }) => {
|
||||
const { getSetting, updateSetting } = vizality.api.settings._fluxProps();
|
||||
const [ currentSource, setCurrentSource ] = useState(source || (type === 'plugin' || type === 'theme' ? [ ...vizality.manager[toPlural(type)].values ] : null));
|
||||
const [ currentTab, setCurrentTab ] = useState(tab || 'installed');
|
||||
const [ displayType, setDisplay ] = useState(display || getSetting('listDisplay', 'card'));
|
||||
const [ banners, setShowBanners ] = useState(showBanners || getSetting('showBanners', false));
|
||||
const [ query, setQuery, filteredResults ] = useFilter({
|
||||
keys: [ 'manifest.name', 'manifest.author.name', 'manifest.author', 'manifest.description' ],
|
||||
data: currentSource
|
||||
});
|
||||
const [ resultsCount, setResultsCount ] = useState(filteredResults.length || 0);
|
||||
const prevSource = usePrevious(source);
|
||||
const forceUpdate = useForceUpdate();
|
||||
const { colorStandard } = getModule('colorStandard');
|
||||
|
||||
/**
|
||||
* Handles results count changes.
|
||||
* @param {number} count Results count
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleResultsCount = count => {
|
||||
return setResultsCount(count);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* There is an issue where it doesn't update currentSource when switching from one
|
||||
* page to another (Plugins to Themes and Themes to Plugins), but it does properly
|
||||
* update source; so here, we check if the previous source prop is equal to the new
|
||||
* source prop, and if it's not, set currentSource to the source prop.
|
||||
*/
|
||||
if (prevSource && source !== prevSource) {
|
||||
setCurrentSource(source);
|
||||
}
|
||||
/**
|
||||
* Add listeners for addons being installed and uninstalled.
|
||||
*/
|
||||
vizality.manager[toPlural(type)].on(Events.VIZALITY_ADDON_INSTALL, forceUpdate);
|
||||
vizality.manager[toPlural(type)].on(Events.VIZALITY_ADDON_UNINSTALL, forceUpdate);
|
||||
return () => {
|
||||
/**
|
||||
* Remove listeners on dismount.
|
||||
*/
|
||||
vizality.manager[toPlural(type)].removeListener(Events.VIZALITY_ADDON_INSTALL, forceUpdate);
|
||||
vizality.manager[toPlural(type)].removeListener(Events.VIZALITY_ADDON_UNINSTALL, forceUpdate);
|
||||
};
|
||||
}, [ source, type, tab, display, search, limit, showBanners, displayType, currentTab, banners, currentSource, resultsCount ]);
|
||||
|
||||
/*
|
||||
* Including these in this component so we can forceUpdate the switches.
|
||||
* There's probably a better way to do it.
|
||||
*/
|
||||
const resetSearchOptions = () => {
|
||||
return setQuery('');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles tab changes.
|
||||
* @param {string} tab Content tab
|
||||
*/
|
||||
const handleTabChange = tab => unstable_batchedUpdates(() => {
|
||||
try {
|
||||
setCurrentTab(tab);
|
||||
if (tab === 'installed') {
|
||||
setCurrentSource([ ...vizality.manager[toPlural(type)].values ]);
|
||||
} else if (tab === 'browse') {
|
||||
setCurrentSource([ ...vizality.manager.community[toPlural(type)].values() ]);
|
||||
}
|
||||
} catch (err) {
|
||||
return _error(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles the "Show Banners" option.
|
||||
* @param {boolean} show Whether to show addon card banners
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleShowBanners = show => {
|
||||
updateSetting('showBanners', show);
|
||||
return setShowBanners(show);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the addon card Display option.
|
||||
* @param {('compact'|'cover'|'card'|'list')} display Addon card display style
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDisplayChange = display => {
|
||||
updateSetting('listDisplay', display);
|
||||
return setDisplay(display);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles updating the current search filter.
|
||||
* @param {string} query Search query
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleQueryChange = query => {
|
||||
return setQuery(typeof query === 'string' ? query : '');
|
||||
};
|
||||
|
||||
const handleClearQuery = () => {
|
||||
return setQuery('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames('vz-addons-list', className, colorStandard)}
|
||||
vz-display={displayType}
|
||||
vz-banners={Boolean(banners) && ''}
|
||||
vz-type={type}
|
||||
>
|
||||
{showOptionsBar && (
|
||||
<StickyBar
|
||||
type={type}
|
||||
query={query}
|
||||
tab={currentTab}
|
||||
display={displayType}
|
||||
handleTabChange={handleTabChange}
|
||||
handleQueryChange={handleQueryChange}
|
||||
handleClearQuery={handleClearQuery}
|
||||
handleDisplayChange={handleDisplayChange}
|
||||
resetSearchOptions={resetSearchOptions}
|
||||
getSetting={getSetting}
|
||||
updateSetting={updateSetting}
|
||||
showBanners={banners}
|
||||
handleShowBanners={handleShowBanners}
|
||||
/>
|
||||
)}
|
||||
<DeferredRender
|
||||
idleTimeout={1500}
|
||||
fallback={
|
||||
<div className='vz-addons-list-inner' vz-loading=''>
|
||||
<Spinner className='vz-addons-list-spinner' />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='vz-addons-list-inner'>
|
||||
<SearchResultsHeader
|
||||
query={query}
|
||||
resultsCount={resultsCount}
|
||||
limit={limit}
|
||||
type={type}
|
||||
tab={currentTab}
|
||||
/>
|
||||
<ContentBody
|
||||
filteredResults={filteredResults}
|
||||
community={currentTab === 'browse'}
|
||||
display={displayType}
|
||||
showBanners={banners}
|
||||
type={type}
|
||||
limit={limit}
|
||||
resultsCount={resultsCount}
|
||||
handleResultsCount={handleResultsCount}
|
||||
/>
|
||||
</div>
|
||||
</DeferredRender>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -0,0 +1,55 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { ContextMenu } from '@vizality/components';
|
||||
import { Messages } from '@vizality/i18n';
|
||||
|
||||
export default memo(({ onClose }) => {
|
||||
return (
|
||||
<ContextMenu.Menu navId='vz-addons-list-sort-filter-menu' onClose={onClose}>
|
||||
<ContextMenu.Group label='Sort'>
|
||||
<ContextMenu.RadioItem
|
||||
id='sort-name'
|
||||
group='sort'
|
||||
label='Name'
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
/>
|
||||
<ContextMenu.RadioItem
|
||||
id='sort-most-stars'
|
||||
group='sort'
|
||||
label='Stars'
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
<ContextMenu.RadioItem
|
||||
id='sort-recently-added'
|
||||
group='sort'
|
||||
label='Published Date'
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
<ContextMenu.RadioItem
|
||||
id='sort-most-downloaded'
|
||||
group='sort'
|
||||
label='Downloads'
|
||||
action={() => void 0}
|
||||
disabled={true}
|
||||
/>
|
||||
</ContextMenu.Group>
|
||||
<ContextMenu.Group label='Filter'>
|
||||
<ContextMenu.CheckboxItem
|
||||
id='filter-enabled'
|
||||
label='Enabled'
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
/>
|
||||
<ContextMenu.CheckboxItem
|
||||
id='filter-disabled'
|
||||
label='Disabled'
|
||||
checked={true}
|
||||
action={() => void 0}
|
||||
/>
|
||||
</ContextMenu.Group>
|
||||
</ContextMenu.Menu>
|
||||
);
|
||||
});
|
@ -1,4 +1,5 @@
|
||||
export { default as AddonUninstallModal } from './AddonUninstallModal';
|
||||
export { default as AddonContextMenu } from './AddonContextMenu';
|
||||
export { default as AddonInfoMessage } from './AddonInfoMessage';
|
||||
export { default as AddonsList } from './AddonsList/List';
|
||||
export { default as AddonCard } from './AddonCard';
|
||||
|
Loading…
Reference in new issue