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

pull/67/head
dperolio 3 years ago
parent 79f5c48b4c
commit 5975cf7a85
No known key found for this signature in database
GPG Key ID: 3E9BBAA710D3DDCE

@ -1,10 +1,9 @@
import React from 'react';
import { toPlural, toTitleCase } from '@vizality/util/string';
import { AddonsList } from '@vizality/components/addon';
import { FormTitle } from '@vizality/components';
import AddonsList from '@vizality/builtins/addon-manager/components/addons/List';
export default {
command: 'manage',
description: 'Allows you to manage your addons directly in chat.',

@ -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;
}
}
}

@ -9,7 +9,7 @@
min-width: 340px;
margin: 10px;
// cursor: pointer;
transition: box-shadow .2s ease-out, transform .2s ease-out, background .2s ease-out;
transition: box-shadow .2s ease-out, background .2s ease-out;
flex: 1;
background: var(--background-secondary-alt);
border-radius: 8px;
@ -21,21 +21,48 @@
height: 0;
margin-top: 0 !important;
margin-bottom: 0 !important;
padding-top: 0;
padding-bottom: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
background-color: transparent;
visibility: hidden;
}
&:hover {
background-color: var(--background-tertiary);
box-shadow: var(--elevation-high);
transform: translateY(-1px);
}
&-header-wrapper {
&-details {
display: flex;
font-size: 14px;
overflow: hidden;
margin: 0 !important;
}
&-detail {
&-wrapper {
overflow: hidden;
display: block !important;
& + & {
margin-left: 12px;
}
}
&-value {
overflow: hidden;
text-overflow: ellipsis;
}
&-value-wrapper {
display: flex;
align-items: center;
}
&-value-icon-wrapper {
margin-right: 5px;
}
}
&-header-wrapper {
flex: 1;
flex-wrap: wrap;
display: flex;
}
&-content-wrapper {
@include vz.size(100%);
@include vz.size(100%, auto);
display: flex;
overflow: hidden;
box-sizing: border-box;
@ -69,6 +96,8 @@
&-image-wrapper {
@include vz.size(100%);
border-radius: 8px;
background: var(--background-primary);
cursor: unset;
}
&-img {
@include vz.size(100%);
@ -91,9 +120,6 @@
text-overflow: ellipsis;
}
}
&-author-wrapper {
display: flex;
}
&-author {
@include vz.ellipsis(100%);
width: fit-content;
@ -164,9 +190,11 @@
// }
// }
&-footer-wrapper {
margin: 16px 16px 0;
margin: 16px 0 0 16px;
padding: 0 0 16px;
border-top: 1px solid var(--background-modifier-accent);
box-sizing: border-box;
max-width: calc(100% - 32px);
}
&-footer {
margin-top: 16px;
@ -174,6 +202,7 @@
justify-content: space-between;
flex: 0;
width: 100%;
overflow: hidden;
&-rating-wrapper,
&-downloads-wrapper,
&-last-updated-wrapper {
@ -210,26 +239,21 @@
}
}
&-section-left {
overflow: hidden;
> div {
margin-right: 20px;
}
}
}
}
.vz-addons-list {
$base: &;
&[vz-previews] {
.vz-addon-card {
padding: 0;
&-header-wrapper {
flex-flow: column;
flex: auto;
}
&-preview {
padding-top: 50%;
height: 0;
}
}
&-placeholder {
border-radius: 8px;
background: var(--text-muted);
opacity: .2;
animation: placeholderPulse-2NB6YJ 3s ease-in-out infinite;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}

@ -4,24 +4,17 @@
$base: &;
&[vz-display='compact'] {
#{$base}-items {
margin: 25px -10px 0;
margin: 25px -5px 0;
}
.vz-addon-card {
padding: 10px;
margin: 5px 10px;
flex: 1 100%;
&:empty {
display: none;
}
padding: 16px;
margin: 5px;
&-header {
padding: 0;
}
&-metadata {
width: 100%;
}
&-version {
margin-bottom: 0;
}
&-name-version {
align-items: center;
}
@ -79,9 +72,14 @@
&-actions {
display: flex;
align-items: center;
padding: 0 10px;
> div {
margin-left: 10px;
cursor: pointer;
button {
@include vz.size(34px);
}
+ div {
margin-left: 10px;
}
> div {
display: flex;
}

@ -2,6 +2,7 @@
.vz-addons-list {
$base: &;
z-index: 1;
&[vz-display='list'] {
#{$base}-items {
margin: 25px -10px 0;
@ -9,40 +10,36 @@
.vz-addon-card {
flex: 1 100%;
margin: 5px 10px;
&-previews-wrapper {
width: 100%;
max-width: 410px;
}
}
&:not([vz-previews]) {
&:not([vz-banners]) {
.vz-addon-card {
&-icon {
@include vz.size(170px);
margin: 10px 0 10px 10px;
}
&-header-wrapper {
display: flex;
flex-wrap: unset;
}
}
}
&[vz-previews] {
&[vz-banners] {
.vz-addon-card {
&-header-wrapper {
display: flex;
flex-flow: row;
flex: 1;
}
// .carousel-22Gq5X {
// height: 100%;
// }
&-previews-empty,
.outer-s4sY2_ {
height: 100%;
&-banner-wrapper {
width: 100%;
max-width: 500px;
flex: auto;
}
.smallCarousel-2e0IQc,
.smallCarouselItem-1rfKEJ {
&-banner-inner {
height: 100%;
padding: 0;
border: none;
border-radius: 8px 0 0 8px;
}
.smallCarousel-2e0IQc {
border-right: 1px solid var(--background-tertiary);
}
&-content-wrapper {
flex-direction: column;
}

@ -4,13 +4,31 @@
display: flex;
flex-flow: column;
flex: 1;
&-page-content {
&::before {
@include vz.size(100%, 350px);
content: '';
-webkit-mask-image: linear-gradient(to top, transparent 0%, #000 70%);
mask-image: linear-gradient(to top, transparent 0%, #000 70%);
background-image: url('vz-asset://images/addons-list-bg-dark.png');
background-repeat: no-repeat;
background-position: center;
background-size: cover;
position: absolute;
top: 0;
left: 0;
z-index: 0;
animation: vz-fade-half 0.3s forwards;
[vz-theme='light'] & {
background-image: url('vz-asset://images/addons-list-bg-light.png');
}
}
}
&-items {
display: flex;
justify-content: center;
flex-flow: row wrap;
opacity: 0;
margin: 20px -10px 20px; // Negative left and right margins to counteract vz-addon-card margin
animation: fadeShortSlideInFromBottom 0.15s forwards;
}
&-inner {
display: flex;
@ -81,17 +99,23 @@
position: sticky;
top: -1px;
border-radius: 8px;
background: var(--background-secondary);
background: var(--background-tertiary);
z-index: 1;
box-shadow: var(--elevation-low);
flex: 0;
&[vz-stuck] {
&.stuck {
background: var(--background-primary);
border-radius: 0;
width: calc(100% + 80px);
margin-left: -40px;
}
}
.topPill-30KHOu .selected-3s45Ha.item-PXvHYJ {
background: var(--brand-experiment);
&:hover {
background: var(--brand-experiment);
}
}
}
}
@ -100,3 +124,12 @@
display: none;
}
}
@keyframes vz-fade-half {
0% {
opacity: 0;
}
100% {
opacity: 0.5;
}
}

@ -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>
);
});

@ -2,9 +2,8 @@ import { ContextMenu, Icon } from '@vizality/components';
import React, { useState, memo } from 'react';
import { Messages } from '@vizality/i18n';
export default memo(props => {
const { onClose, handleDisplayChange, showPreviewImages, handleShowPreviewImages, display: _display } = props;
const [ previewImages, setShowPreviewImages ] = useState(showPreviewImages);
export default memo(({ onClose, handleDisplayChange, showBanners, handleShowBanners, display: _display }) => {
const [ banners, setShowBanners ] = useState(showBanners);
const [ display, setDisplay ] = useState(_display);
return (
@ -76,10 +75,10 @@ export default memo(props => {
id='show-banners'
label='Show Banners'
disabled={display === 'compact' || display === 'cover'}
checked={previewImages}
checked={banners}
action={() => {
setShowPreviewImages(!previewImages);
handleShowPreviewImages(!previewImages);
setShowBanners(!banners);
handleShowBanners(!banners);
}}
/>
</ContextMenu.Menu>

@ -6,9 +6,7 @@ import { Directories } from '@vizality/constants';
import { toPlural } from '@vizality/util/string';
import { Messages } from '@vizality/i18n';
export default memo(props => {
const { type, resetSearchOptions, enableAll, disableAll, onClose } = props;
export default memo(({ type, resetSearchOptions, onClose }) => {
return (
<ContextMenu.Menu navId='vz-addons-list-overflow-menu' onClose={onClose}>
<ContextMenu.Item
@ -25,12 +23,12 @@ export default memo(props => {
<ContextMenu.Item
id='enable-all'
label='Enable All'
action={async () => enableAll(type)}
action={async () => vizality.manager[toPlural(type)].enableAll()}
/>
<ContextMenu.Item
id='disable-all'
label='Disable All'
action={async () => disableAll(type)}
action={async () => vizality.manager[toPlural(type)].disableAll()}
/>
</ContextMenu.Menu>
);

@ -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>
);
});

@ -4,8 +4,7 @@ import { ContextMenu, SearchBar } from '@vizality/components';
import { toTitleCase } from '@vizality/util/string';
import { Messages } from '@vizality/i18n';
export default memo(props => {
const { type, onClose } = props;
export default memo(({ type, onClose }) => {
const [ query, setQuery ] = useState('');
const [ , forceUpdate ] = useReducer(x => x + 1, 0);

@ -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…
Cancel
Save