feat(marketplace): use web ui

This commit is contained in:
David Ralph
2026-01-03 17:25:34 +00:00
parent 73bc89d7ae
commit 0431fc830d
32 changed files with 266 additions and 2043 deletions

View File

@@ -26,6 +26,7 @@ function MainModal({ modalClose, deepLinkData }) {
const [productView, setProductView] = useState(null); const [productView, setProductView] = useState(null);
const [resetDiscoverToAll, setResetDiscoverToAll] = useState(false); const [resetDiscoverToAll, setResetDiscoverToAll] = useState(false);
const [navigationTrigger, setNavigationTrigger] = useState(null); const [navigationTrigger, setNavigationTrigger] = useState(null);
const [iframeBreadcrumbs, setIframeBreadcrumbs] = useState([]);
// Clear product view when changing tabs // Clear product view when changing tabs
useEffect(() => { useEffect(() => {
@@ -172,6 +173,7 @@ function MainModal({ modalClose, deepLinkData }) {
currentTab={currentTab} currentTab={currentTab}
currentSection={currentSection} currentSection={currentSection}
productView={productView} productView={productView}
iframeBreadcrumbs={iframeBreadcrumbs}
onTabChange={handleChangeTab} onTabChange={handleChangeTab}
onClose={modalClose} onClose={modalClose}
onBack={handleBack} onBack={handleBack}
@@ -187,6 +189,7 @@ function MainModal({ modalClose, deepLinkData }) {
currentTab={currentTab} currentTab={currentTab}
onSectionChange={handleSectionChange} onSectionChange={handleSectionChange}
onProductView={handleProductView} onProductView={handleProductView}
onBreadcrumbsChange={setIframeBreadcrumbs}
resetToAll={resetDiscoverToAll} resetToAll={resetDiscoverToAll}
onResetToAll={handleResetDiscoverToAll} onResetToAll={handleResetDiscoverToAll}
navigationTrigger={navigationTrigger} navigationTrigger={navigationTrigger}

View File

@@ -47,8 +47,8 @@ const Tabs = ({ children, navbar = false, currentTab: activeTab, onSectionChange
setShowReminder(false); setShowReminder(false);
}; };
// Only show sidebar for Settings tab // Show sidebar for Settings and Discover tabs
const showSidebar = activeTab === TAB_TYPES.SETTINGS; const showSidebar = activeTab === TAB_TYPES.SETTINGS || activeTab === TAB_TYPES.DISCOVER;
return ( return (
<div style={{ display: 'flex', width: '100%', minHeight: '100%' }}> <div style={{ display: 'flex', width: '100%', minHeight: '100%' }}>

View File

@@ -20,6 +20,7 @@ function ModalTopBar({
currentTab, currentTab,
currentSection, currentSection,
productView, productView,
iframeBreadcrumbs,
onTabChange, onTabChange,
onClose, onClose,
onBack, onBack,
@@ -34,7 +35,7 @@ function ModalTopBar({
: ''; : '';
// Determine breadcrumb path with click handlers // Determine breadcrumb path with click handlers
const breadcrumbPath = []; let breadcrumbPath = [];
if (currentTabLabel) { if (currentTabLabel) {
breadcrumbPath.push({ breadcrumbPath.push({
@@ -42,7 +43,26 @@ function ModalTopBar({
onClick: productView ? productView.onBackToAll : null, // Clickable if viewing a product onClick: productView ? productView.onBackToAll : null, // Clickable if viewing a product
}); });
if (productView) { // Check if we have iframe breadcrumbs (from Discover iframe)
// If so, only use the last item (the item name) and keep our section
if (iframeBreadcrumbs && iframeBreadcrumbs.length > 0) {
// Get the last breadcrumb item (the item name)
const lastCrumb = iframeBreadcrumbs[iframeBreadcrumbs.length - 1];
// Add current section if available
if (currentSection) {
breadcrumbPath.push({
label: currentSection,
onClick: () => onBack(), // Clickable to go back
});
}
// Add the item name from iframe
breadcrumbPath.push({
label: lastCrumb.label,
onClick: null, // Current item - not clickable
});
} else if (productView) {
console.log('ModalTopBar productView:', productView); console.log('ModalTopBar productView:', productView);
console.log('fromCollection:', productView.fromCollection, 'isCollection:', productView.isCollection, 'collectionTitle:', productView.collectionTitle); console.log('fromCollection:', productView.fromCollection, 'isCollection:', productView.isCollection, 'collectionTitle:', productView.collectionTitle);

View File

@@ -6,6 +6,7 @@ export const OPENSTREETMAP_URL = 'https://www.openstreetmap.org';
// Mue URLs // Mue URLs
export const WEBSITE_URL = 'https://muetab.com'; export const WEBSITE_URL = 'https://muetab.com';
export const MARKETPLACE_URL = 'https://marketplace.muetab.com';
export const PRIVACY_URL = 'https://muetab.com/privacy'; export const PRIVACY_URL = 'https://muetab.com/privacy';
export const TRANSLATIONS_URL = 'https://muetab.com/docs/translations'; export const TRANSLATIONS_URL = 'https://muetab.com/docs/translations';
export const WEBLATE_URL = 'https://hosted.weblate.org/projects/mue/mue-tab/'; export const WEBLATE_URL = 'https://hosted.weblate.org/projects/mue/mue-tab/';

View File

@@ -1,46 +0,0 @@
import { MdOutlineArrowForward, MdOutlineOpenInNew } from 'react-icons/md';
import { Button } from 'components/Elements';
import variables from 'config/variables';
const Collection = ({ collection, collectionFunction }) => {
const { news, background_colour, img, display_name, description } = collection;
const getStyle = () => {
if (news) {
return { backgroundColor: background_colour };
}
return {
backgroundImage: `linear-gradient(to left, #000, transparent, #000), url('${img}')`,
};
};
return (
<div className="collection" style={getStyle()}>
<div className="content">
<span className="title">{display_name} using component</span>
<span className="subtitle">{description ? description.substr(0, 75) : ''}</span>
</div>
{collection.news === true ? (
<a
className="btn-collection"
href={collection.news_link}
target="_blank"
rel="noopener noreferrer"
>
{variables.getMessage('modals.main.marketplace.learn_more')} <MdOutlineOpenInNew />
</a>
) : (
<Button
type="collection"
onClick={() => collectionFunction(collection.name)}
icon={<MdOutlineArrowForward />}
label={variables.getMessage('modals.main.marketplace.explore_collection')}
iconPlacement={'right'}
/>
)}
</div>
);
};
export { Collection as default, Collection };

View File

@@ -1 +0,0 @@
export * from './Collection';

View File

@@ -1,86 +0,0 @@
import React, { useState, useEffect, useCallback, useRef, memo } from 'react';
import { MdOutlineArrowForwardIos, MdOutlineArrowBackIos } from 'react-icons/md';
import useEmblaCarousel from 'embla-carousel-react';
import Autoplay from 'embla-carousel-autoplay';
import './carousel.scss';
function EmblaCarousel({ data }) {
const autoplay = useRef(
Autoplay({ delay: 3000, stopOnInteraction: false }, (emblaRoot) => emblaRoot.parentElement),
);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false }, [autoplay.current]);
const [prevBtnEnabled, setPrevBtnEnabled] = useState(false);
const [nextBtnEnabled, setNextBtnEnabled] = useState(false);
const scroll = useCallback(
(direction) => {
if (!emblaApi) {
return;
}
if (direction === 'next') {
emblaApi.scrollNext();
} else {
emblaApi.scrollPrev();
}
autoplay.current.reset();
},
[emblaApi],
);
const onSelect = useCallback(() => {
if (!emblaApi) {
return;
}
setPrevBtnEnabled(emblaApi.canScrollPrev());
setNextBtnEnabled(emblaApi.canScrollNext());
}, [emblaApi]);
useEffect(() => {
if (!emblaApi) {
return;
}
onSelect();
emblaApi.on('select', onSelect);
}, [emblaApi, onSelect]);
return (
<div className="carousel">
<div className="carousel_viewport" ref={emblaRef}>
<div className="carousel_container">
{data?.slice(0, 5).map((photo, index) => (
<div className="carousel_slide" key={index}>
<div className="carousel_slide_inner">
<img src={photo.url.default} alt="Marketplace example screenshot" />
</div>
</div>
))}
</div>
</div>
{data.length > 1 && (
<>
<button
className="carousel_button prev btn-icon"
onClick={() => scroll('prev')}
disabled={!prevBtnEnabled}
>
<MdOutlineArrowBackIos />
</button>
<button
className="carousel_button next btn-icon"
onClick={() => scroll('next')}
disabled={!nextBtnEnabled}
>
<MdOutlineArrowForwardIos />
</button>
</>
)}
</div>
);
}
const Carousel = memo(EmblaCarousel);
export { Carousel as default, Carousel };

View File

@@ -1,83 +0,0 @@
.carousel {
position: relative;
width: 100%;
margin-left: 5px;
}
.carousel_viewport {
overflow: hidden;
width: 100%;
&.is-draggable {
cursor: move;
}
&.is-dragging {
cursor: grabbing;
}
border-radius: 15px !important;
}
.carousel_container {
display: flex;
-webkit-touch-callout: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
margin-left: -10px;
}
.carousel_slide {
position: relative;
min-width: 100%;
padding-left: 10px;
img {
position: absolute;
display: block;
top: 50%;
left: 50%;
width: auto;
min-height: 100%;
min-width: 100%;
max-width: none;
transform: translate(-50%, -50%);
}
}
.carousel_slide_inner {
position: relative;
overflow: hidden;
height: 250px;
}
.carousel_button {
outline: 0;
cursor: pointer;
touch-action: manipulation;
position: absolute;
z-index: 1;
top: 50%;
transform: translateY(-50%);
border: 0;
width: 30px !important;
height: 30px !important;
display: grid;
place-items: center;
justify-content: center;
align-items: center;
padding: 0;
&:disabled {
cursor: default;
opacity: 0.3;
}
&.prev {
left: 27px;
}
&.next {
right: 27px;
}
}

View File

@@ -1 +0,0 @@
export * from './Carousel';

View File

@@ -1,113 +0,0 @@
import { memo } from 'react';
import { MdClose } from 'react-icons/md';
import './installButton.scss';
const MueLogo = () => (
<svg
width="24"
height="24"
viewBox="0 0 500 500"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mueLogo"
>
<circle cx="250" cy="250" r="250" fill="url(#paint0_linear_1870_2)" />
<path
d="M291.02 132.002V164.879H362.13V235.422H395.302V132.002H291.02Z"
fill="url(#paint1_linear_1870_2)"
/>
<path
d="M314.592 241.186H285.201V270.542H258.081V241.186H228.669V214.119H258.081V184.784H285.201V214.119H314.592V241.186ZM354.25 171.261H283.307V146.651H164.332V308.676H378.929V242.089H354.25V171.261Z"
fill="url(#paint2_linear_1870_2)"
/>
<path
d="M156.949 176.811H134.164V338.836H348.761V316.031H156.949V176.811Z"
fill="url(#paint3_linear_1870_2)"
/>
<path
d="M126.785 206.975H104V369H318.597V346.195H126.785V206.975Z"
fill="url(#paint4_linear_1870_2)"
/>
<defs>
<linearGradient
id="paint0_linear_1870_2"
x1="462"
y1="120"
x2="29.5"
y2="383"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF5C25" />
<stop offset="0.484375" stopColor="#D21A11" />
<stop offset="1" stopColor="#FF456E" />
</linearGradient>
<linearGradient
id="paint1_linear_1870_2"
x1="343.161"
y1="235.422"
x2="343.161"
y2="132.002"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F18D91" />
<stop offset="1" stopColor="#FBD3C6" />
</linearGradient>
<linearGradient
id="paint2_linear_1870_2"
x1="271.631"
y1="308.676"
x2="271.631"
y2="146.651"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F18D91" />
<stop offset="1" stopColor="#FBD3C6" />
</linearGradient>
<linearGradient
id="paint3_linear_1870_2"
x1="241.463"
y1="338.836"
x2="241.463"
y2="176.811"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F18D91" />
<stop offset="1" stopColor="#FBD3C6" />
</linearGradient>
<linearGradient
id="paint4_linear_1870_2"
x1="211.298"
y1="369"
x2="211.298"
y2="206.975"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F18D91" />
<stop offset="1" stopColor="#FBD3C6" />
</linearGradient>
</defs>
</svg>
);
const InstallButton = ({ onClick, isInstalled, label }) => {
return (
<button
className={`installButton ${isInstalled ? 'installed' : 'notInstalled'}`}
onClick={onClick}
>
<span className="buttonContent">
<span className="labelText">{label}</span>
<span className="iconWrapper">
<span className={`icon installIcon ${isInstalled ? 'hide' : 'show'}`}>
<MueLogo />
</span>
<span className={`icon removeIcon ${isInstalled ? 'show' : 'hide'}`}>
<MdClose />
</span>
</span>
</span>
</button>
);
};
export default memo(InstallButton);

View File

@@ -1 +0,0 @@
export { default } from './InstallButton';

View File

@@ -1,166 +0,0 @@
.installButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
min-width: 180px;
.buttonContent {
display: flex;
align-items: center;
gap: 10px;
position: relative;
z-index: 1;
}
.iconWrapper {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.icon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
&.show {
opacity: 1;
transform: rotate(0deg) scale(1);
}
&.hide {
opacity: 0;
transform: rotate(90deg) scale(0.3);
}
}
.mueLogo {
width: 24px;
height: 24px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.removeIcon {
svg {
width: 24px;
height: 24px;
}
}
.labelText {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
// Install state (not installed yet)
&.notInstalled {
background: linear-gradient(135deg, #ff5c25 0%, #d21a11 48%, #ff456e 100%);
color: white;
// box-shadow: 0 4px 12px rgba(255, 92, 37, 0.3);
&:hover {
// box-shadow: 0 6px 16px rgba(255, 92, 37, 0.4);
.mueLogo {
animation: logoFloat 0.6s ease-in-out;
}
// .labelText {
// transform: translateX(2px);
// }
}
&:active {
box-shadow: 0 2px 8px rgba(255, 92, 37, 0.2);
}
}
// Installed state (already installed)
&.installed {
background: rgba(255, 255, 255, 0.1);
color: white;
// border: 2px solid rgba(255, 255, 255, 0.2);
// backdrop-filter: blur(10px);
&:hover {
background: rgba(255, 92, 37, 0.2);
border-color: rgba(255, 92, 37, 0.4);
// transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(255, 92, 37, 0.2);
.removeIcon {
animation: rotateIcon 0.4s ease-in-out;
}
// .labelText {
// transform: translateX(2px);
// }
}
&:active {
transform: translateY(0);
}
}
// Loading state (optional - for future use)
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
&:hover {
transform: none !important;
}
}
}
@keyframes logoFloat {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes rotateIcon {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(90deg);
}
}
// Smooth state transition animation
.installButton.notInstalled.transitioning,
.installButton.installed.transitioning {
animation: buttonPulse 0.5s ease-out;
}
@keyframes buttonPulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}

View File

@@ -1,3 +1,2 @@
export * from './Carousel';
export * from './SideloadFailedModal'; export * from './SideloadFailedModal';
export * from './Lightbox'; export * from './Lightbox';

View File

@@ -68,4 +68,4 @@ export const useMarketplaceInstall = () => {
uninstallItem, uninstallItem,
installCollection, installCollection,
}; };
}; };

View File

@@ -1,13 +0,0 @@
import { createContext, useContext } from 'react';
const MarketplaceContext = createContext(null);
export const useMarketplace = () => {
const context = useContext(MarketplaceContext);
if (!context) {
throw new Error('useMarketplace must be used within a MarketplaceProvider');
}
return context;
};
export { MarketplaceContext };

View File

@@ -1,104 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import variables from 'config/variables';
const API_V2_BASE = `${variables.constants.API_URL}/marketplace`;
export const useMarketplaceData = (type, deepLinkData) => {
const [items, setItems] = useState([]);
const [collections, setCollections] = useState([]);
const [displayedCollection, setDisplayedCollection] = useState({});
const [sortType, setSortType] = useState('a-z');
const [loading, setLoading] = useState(false);
const controllerRef = useRef(new AbortController());
const sortItems = (itemsToSort, sortValue) => {
if (!itemsToSort) return [];
const sorted = [...itemsToSort];
const value = sortValue || localStorage.getItem('sortMarketplace') || 'a-z';
switch (value) {
case 'a-z':
sorted.sort((a, b) => {
if (a.display_name < b.display_name) return -1;
if (a.display_name > b.display_name) return 1;
return 0;
});
break;
case 'z-a':
sorted.sort((a, b) => {
if (a.display_name < b.display_name) return -1;
if (a.display_name > b.display_name) return 1;
return 0;
});
sorted.reverse();
break;
default:
break;
}
return sorted;
};
const fetchItems = async () => {
setLoading(true);
try {
const dataURL =
type === 'collections' ? `${API_V2_BASE}/collections` : `${API_V2_BASE}/items/${type}`;
const [itemsResponse, collectionsResponse] = await Promise.all([
fetch(dataURL, { signal: controllerRef.current.signal }),
fetch(`${API_V2_BASE}/collections`, { signal: controllerRef.current.signal }),
]);
const itemsData = await itemsResponse.json();
const collectionsData = await collectionsResponse.json();
if (controllerRef.current.signal.aborted) return;
const sortedItems = sortItems(itemsData.data, sortType);
setItems(sortedItems);
setCollections(collectionsData.data);
setDisplayedCollection(
collectionsData.data[Math.floor(Math.random() * collectionsData.data.length)] || {},
);
} catch (error) {
if (!controllerRef.current.signal.aborted) {
console.error('Failed to fetch marketplace data:', error);
}
} finally {
setLoading(false);
}
};
const changeSort = (value) => {
localStorage.setItem('sortMarketplace', value);
const sorted = sortItems(items, value);
setItems(sorted);
setSortType(value);
variables.stats.postEvent('marketplace', 'Sort');
};
useEffect(() => {
if (navigator.onLine === false || localStorage.getItem('offlineMode') === 'true') {
return;
}
fetchItems();
return () => {
controllerRef.current.abort();
};
}, [type]);
return {
items,
collections,
displayedCollection,
sortType,
loading,
changeSort,
refetch: fetchItems,
};
};

View File

@@ -1,223 +0,0 @@
import { useState, useRef, useCallback } from 'react';
import { toast } from 'react-toastify';
import variables from 'config/variables';
import { urlParser } from 'utils/marketplace';
import { updateHash } from 'utils/deepLinking';
const API_V2_BASE = `${variables.constants.API_URL}/marketplace`;
export const useMarketplaceNavigation = (onProductView, onResetToAll) => {
const [currentView, setCurrentView] = useState('browse'); // 'browse', 'collection', 'item'
const [currentItem, setCurrentItem] = useState(null);
const [currentCollection, setCurrentCollection] = useState(null);
const [relatedItems, setRelatedItems] = useState([]);
const controllerRef = useRef(new AbortController());
const navigateToItem = useCallback(
async (data, type) => {
try {
let itemType = type;
if (type === 'all' || type === 'collections') {
itemType = data.type;
}
// Fetch item details
const itemEndpoint = data.id
? `${API_V2_BASE}/item/${data.id}`
: `${API_V2_BASE}/item/${itemType}/${data.name}`;
const response = await fetch(itemEndpoint, {
signal: controllerRef.current.signal,
});
const info = await response.json();
if (!info || !info.data) {
throw new Error('Invalid item data received');
}
// Fetch related items
let related = [];
if (info.data?.id) {
try {
const relatedResponse = await fetch(`${API_V2_BASE}/item/${info.data.id}/related`, {
signal: controllerRef.current.signal,
});
const relatedData = await relatedResponse.json();
related = relatedData.data?.related || [];
} catch (error) {
console.warn('Failed to fetch related items:', error);
}
// Track view
fetch(`${API_V2_BASE}/item/${info.data.id}/view`, {
method: 'POST',
signal: controllerRef.current.signal,
}).catch(() => {});
}
// Check if installed
const installed = JSON.parse(localStorage.getItem('installed')) || [];
const isInstalled = installed.some((item) => item.name === info.data.name);
const installedVersion = installed.find((item) => item.name === info.data.name)?.version;
const itemData = {
id: info.data.id,
onCollection: data._onCollection,
type: info.data.type,
display_name: info.data.name || info.data.display_name,
author: info.data.author,
description: urlParser(info.data.description.replace(/\n/g, '<br>')),
version: info.data.version,
icon: info.data.screenshot_url,
data: info.data,
addonInstalled: isInstalled,
addonInstalledVersion: installedVersion,
api_name: data.name,
};
setCurrentItem(itemData);
setRelatedItems(related);
setCurrentView('item');
// Update hash
if (info.data?.id) {
if (currentCollection?.name) {
updateHash(`#discover/collection/${currentCollection.name}/${info.data.id}`);
} else {
updateHash(`#discover/${info.data.type}/${info.data.id}`);
}
}
// Notify parent
if (onProductView) {
onProductView({
id: info.data.id,
type: info.data.type,
name: info.data.display_name || info.data.name,
fromCollection: !!currentCollection,
collectionName: currentCollection?.name,
collectionTitle: currentCollection?.title,
collectionDescription: currentCollection?.description,
collectionImg: currentCollection?.img,
onBack: () => navigateBack(),
onBackToAll: () => navigateToMain(true),
});
}
document.querySelector('#modal')?.scrollTo(0, 0);
variables.stats.postEvent('marketplace-item', `${itemData.display_name} viewed`);
} catch (error) {
if (!controllerRef.current.signal.aborted) {
console.error('Failed to fetch item:', error);
toast(variables.getMessage('toasts.error'));
}
}
},
[currentCollection, onProductView],
);
const navigateToCollection = useCallback(
async (collectionName) => {
try {
const response = await fetch(`${API_V2_BASE}/collection/${collectionName}`, {
signal: controllerRef.current.signal,
});
const collection = await response.json();
if (controllerRef.current.signal.aborted) return;
const collectionData = {
name: collectionName,
title: collection.data.display_name,
description: collection.data.description,
img: collection.data.img,
items: collection.data.items,
};
setCurrentCollection(collectionData);
setCurrentView('collection');
setCurrentItem(null);
setRelatedItems([]);
// Update hash
updateHash(`#discover/collection/${collectionName}`);
// Notify parent
if (onProductView) {
onProductView({
id: collectionName,
type: 'collection',
name: collection.data.display_name,
isCollection: true,
fromCollection: false,
collectionName: collectionName,
collectionTitle: collection.data.display_name,
onBack: () => navigateToMain(false),
onBackToAll: () => navigateToMain(true),
});
}
} catch (error) {
if (!controllerRef.current.signal.aborted) {
console.error('Failed to fetch collection:', error);
toast(variables.getMessage('toasts.error'));
}
}
},
[onProductView],
);
const navigateBack = useCallback(() => {
if (currentView === 'item' && currentCollection) {
// Go back to collection
setCurrentView('collection');
setCurrentItem(null);
setRelatedItems([]);
updateHash(`#discover/collection/${currentCollection.name}`);
} else {
// Go back to browse
navigateToMain(false);
}
if (onProductView) {
onProductView(null);
}
}, [currentView, currentCollection, onProductView]);
const navigateToMain = useCallback(
(clearCollection) => {
if (clearCollection) {
setCurrentCollection(null);
}
setCurrentView('browse');
setCurrentItem(null);
setRelatedItems([]);
// Update hash based on collection state
if (!clearCollection && currentCollection) {
updateHash(`#discover/collection/${currentCollection.name}`);
} else {
updateHash('#discover/all');
}
if (onProductView) {
onProductView(null);
}
if (clearCollection && onResetToAll) {
onResetToAll();
}
},
[currentCollection, onProductView, onResetToAll],
);
return {
currentView,
currentItem,
currentCollection,
relatedItems,
navigateToItem,
navigateToCollection,
navigateBack,
navigateToMain,
};
};

View File

@@ -5,32 +5,19 @@ import { toast } from 'react-toastify';
import Modal from 'react-modal'; import Modal from 'react-modal';
import { SideloadFailedModal } from '../components/Elements/SideloadFailedModal/SideloadFailedModal'; import { SideloadFailedModal } from '../components/Elements/SideloadFailedModal/SideloadFailedModal';
import ItemPage from './ItemPage';
import Items from '../components/Items/Items'; import Items from '../components/Items/Items';
import { Dropdown, FileUpload } from 'components/Form/Settings'; import { Dropdown, FileUpload } from 'components/Form/Settings';
import { Header, CustomActions } from 'components/Layout/Settings'; import { Header, CustomActions } from 'components/Layout/Settings';
import { Button } from 'components/Elements'; import { Button } from 'components/Elements';
import InstallButton from '../components/Elements/InstallButton';
import { install, uninstall, urlParser } from 'utils/marketplace'; import { install, uninstall } from 'utils/marketplace';
import { updateHash } from 'utils/deepLinking'; import { updateHash } from 'utils/deepLinking';
const Added = memo(() => { const Added = memo(() => {
const [installed, setInstalled] = useState(JSON.parse(localStorage.getItem('installed'))); const [installed, setInstalled] = useState(JSON.parse(localStorage.getItem('installed')));
const [item, setItem] = useState({});
const [showFailed, setShowFailed] = useState(false); const [showFailed, setShowFailed] = useState(false);
const [failedReason, setFailedReason] = useState(''); const [failedReason, setFailedReason] = useState('');
const uninstallItem = useCallback(() => {
uninstall(item.type, item.display_name);
toast(variables.getMessage('toasts.uninstalled'));
setItem({});
setInstalled(JSON.parse(localStorage.getItem('installed')));
variables.stats.postEvent('marketplace', 'Uninstall');
}, [item.type, item.display_name]);
const installAddon = useCallback((input) => { const installAddon = useCallback((input) => {
let failedReasonText = ''; let failedReasonText = '';
if (!input.name) { if (!input.name) {
@@ -83,22 +70,15 @@ const Added = memo(() => {
const toggle = useCallback((type, data) => { const toggle = useCallback((type, data) => {
if (type === 'item') { if (type === 'item') {
const installedItems = JSON.parse(localStorage.getItem('installed')); // Navigate to discover tab with the item
const info = { data: installedItems.find((i) => i.name === data.name) }; const itemId = data.name;
updateHash(`#discover/all?item=${itemId}`);
setItem({
type: info.data.type, // Trigger navigation
display_name: info.data.name, const event = new window.Event('popstate');
author: info.data.author, window.dispatchEvent(event);
description: urlParser(info.data.description.replace(/\n/g, '<br>')),
version: info.data.version,
icon: info.data.screenshot_url,
data: info.data,
});
variables.stats.postEvent('marketplace', 'ItemPage viewed'); variables.stats.postEvent('marketplace', 'ItemPage viewed');
} else {
setItem({});
} }
}, []); }, []);
@@ -173,14 +153,6 @@ const Added = memo(() => {
sortAddons(localStorage.getItem('sortAddons'), false); sortAddons(localStorage.getItem('sortAddons'), false);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const button = item.display_name ? (
<InstallButton
onClick={uninstallItem}
isInstalled={true}
label={variables.getMessage('modals.main.marketplace.product.buttons.remove')}
/>
) : '';
const sideLoadBackendElements = () => ( const sideLoadBackendElements = () => (
<> <>
<Modal <Modal
@@ -240,17 +212,6 @@ const Added = memo(() => {
); );
} }
if (item.display_name) {
return (
<ItemPage
data={item}
button={button}
addons={true}
toggleFunction={() => toggle()}
/>
);
}
return ( return (
<> <>
<Header title={variables.getMessage('modals.main.addons.added')} report={false}> <Header title={variables.getMessage('modals.main.addons.added')} report={false}>

View File

@@ -1,222 +0,0 @@
import variables from 'config/variables';
import { useState, useEffect } from 'react';
import { MdWifiOff, MdLocalMall } from 'react-icons/md';
import ItemPage from './ItemPage';
import CollectionPage from './CollectionPage';
import BrowsePage from './BrowsePage';
import InstallButton from '../components/Elements/InstallButton';
import { useMarketplaceData } from '../hooks/useMarketplaceData';
import { useMarketplaceNavigation } from '../hooks/useMarketplaceNavigation';
import { useMarketplaceInstall } from '../hooks/useMarketplaceInstall';
import { MarketplaceContext } from '../contexts/MarketplaceContext';
const Marketplace = ({ type, onProductView, onResetToAll, deepLinkData, navigationTrigger }) => {
const [filter, setFilter] = useState('');
// Custom hooks for data, navigation, and installation
const { items, displayedCollection, loading, changeSort } = useMarketplaceData(type, deepLinkData);
const {
currentView,
currentItem,
currentCollection,
relatedItems,
navigateToItem,
navigateToCollection,
navigateBack,
navigateToMain,
} = useMarketplaceNavigation(onProductView, onResetToAll);
const { busy, installItem, uninstallItem, installCollection } = useMarketplaceInstall();
const handleManage = (action) => {
if (!currentItem) return;
if (action === 'install') {
installItem(currentItem.type, currentItem.data);
} else {
uninstallItem(currentItem.type, currentItem.display_name);
}
};
// Install/uninstall buttons
const buttons = {
uninstall: (
<InstallButton
onClick={() => handleManage('uninstall')}
isInstalled={true}
label={variables.getMessage('modals.main.marketplace.product.buttons.remove')}
/>
),
install: (
<InstallButton
onClick={() => handleManage('install')}
isInstalled={false}
label={variables.getMessage('modals.main.marketplace.product.buttons.addtomue')}
/>
),
};
// Get appropriate button based on installation status
const getButton = () => {
if (!currentItem) return buttons.install;
return currentItem.addonInstalled ? buttons.uninstall : buttons.install;
};
// Handle deep linking on mount
useEffect(() => {
if (deepLinkData) {
const { itemId, collection, category } = deepLinkData;
setTimeout(() => {
if (collection) {
navigateToCollection(collection);
} else if (itemId) {
navigateToItem({ id: itemId, type: category }, type);
}
}, 500);
}
}, [deepLinkData, navigateToCollection, navigateToItem, type]);
// Handle navigation trigger changes
useEffect(() => {
if (navigationTrigger) {
const { type: navType, data } = navigationTrigger;
if (navType === 'product' && data) {
navigateToItem({ id: data.id, type: data.type }, type);
} else if (navType === 'collection' && data) {
navigateToCollection(data);
} else if (navType === 'main' || navType === 'section') {
const clearCollection = data?.clearCollection && currentCollection;
navigateToMain(clearCollection);
}
}
}, [navigationTrigger, navigateToItem, navigateToCollection, navigateToMain, currentCollection, type]);
// Context value to share with child components
const contextValue = {
currentView,
currentItem,
currentCollection,
items,
navigateToItem: (data) => navigateToItem(data, type),
navigateToCollection,
navigateBack,
navigateToMain,
installItem,
uninstallItem,
};
// Error message component
const renderError = (content) => (
<>
<div className="flexTopMarketplace">
<span className="mainTitle">{variables.getMessage('modals.main.navbar.marketplace')}</span>
</div>
<div className="emptyItems">
<div className="emptyMessage">{content}</div>
</div>
</>
);
// Offline check
if (navigator.onLine === false || localStorage.getItem('offlineMode') === 'true') {
return renderError(
<>
<MdWifiOff />
<span className="title">
{variables.getMessage('modals.main.marketplace.offline.title')}
</span>
<span className="subtitle">
{variables.getMessage('modals.main.marketplace.offline.description')}
</span>
</>,
);
}
// Loading state
if (loading) {
return renderError(
<div className="loaderHolder">
<div id="loader"></div>
<span className="subtitle">{variables.getMessage('modals.main.loading')}</span>
</div>,
);
}
// Empty state
if (!items || items.length === 0) {
return renderError(
<>
<MdLocalMall />
<span className="title">{variables.getMessage('modals.main.addons.empty.title')}</span>
<span className="subtitle">
{variables.getMessage('modals.main.marketplace.no_items')}
</span>
</>,
);
}
// Render item view
if (currentView === 'item' && currentItem) {
return (
<MarketplaceContext.Provider value={contextValue}>
<ItemPage
data={currentItem}
button={getButton()}
toggleFunction={(pageType, data) => {
if (pageType === 'collection') {
navigateToCollection(data);
} else if (pageType === 'main') {
navigateBack();
} else {
navigateToItem(data, type);
}
}}
addonInstalled={currentItem.addonInstalled}
addonInstalledVersion={currentItem.addonInstalledVersion}
icon={currentItem.icon}
relatedItems={relatedItems}
/>
</MarketplaceContext.Provider>
);
}
// Render collection view
if (currentView === 'collection' && currentCollection) {
return (
<MarketplaceContext.Provider value={contextValue}>
<CollectionPage
collectionName={currentCollection.name}
collectionTitle={currentCollection.title}
collectionDescription={currentCollection.description}
collectionImg={currentCollection.img}
items={currentCollection.items}
busy={busy}
onInstallCollection={() => installCollection(currentCollection.items)}
onItemClick={(item) => navigateToItem(item, type)}
onSortChange={changeSort}
/>
</MarketplaceContext.Provider>
);
}
// Render browse view
return (
<MarketplaceContext.Provider value={contextValue}>
<BrowsePage
type={type}
items={items}
featuredCollection={displayedCollection}
filter={filter}
onFilterChange={(event) => setFilter(event.target.value)}
onItemClick={(item) => navigateToItem(item, type)}
onCollectionClick={navigateToCollection}
onSortChange={changeSort}
/>
</MarketplaceContext.Provider>
);
};
export default Marketplace;

View File

@@ -1,83 +0,0 @@
import variables from 'config/variables';
import { MdSearch, MdOutlineArrowForward } from 'react-icons/md';
import { Button } from 'components/Elements';
import Items from '../components/Items/Items';
const BrowsePage = ({
type,
items,
featuredCollection,
filter,
onFilterChange,
onItemClick,
onCollectionClick,
onSortChange,
}) => {
return (
<>
<div className="headerExtras marketplaceCondition">
{type !== 'collections' && (
<div>
<form className="marketplaceSearch">
<input
label={variables.getMessage('widgets.search')}
placeholder={variables.getMessage('widgets.search')}
name="filter"
id="filter"
value={filter}
onChange={onFilterChange}
/>
<MdSearch />
</form>
</div>
)}
</div>
{type === 'collections' ? (
items.map((item) =>
!item.news ? (
<div
key={item.name}
className="collection"
style={
item.news
? { backgroundColor: item.background_colour }
: {
backgroundImage: `linear-gradient(to left, #000, transparent, #000), url('${item.img}')`,
}
}
>
<div className="content">
<span className="title">{item.display_name}</span>
<span className="subtitle">{item.description}</span>
</div>
<Button
type="collection"
onClick={() => onCollectionClick(item.name)}
icon={<MdOutlineArrowForward />}
label={variables.getMessage('modals.main.marketplace.explore_collection')}
iconPlacement="right"
/>
</div>
) : null,
)
) : (
<Items
filterOptions={true}
type={type}
items={items}
collection={featuredCollection}
onCollection={false}
toggleFunction={onItemClick}
collectionFunction={onCollectionClick}
filter={filter}
onSortChange={onSortChange}
showCreateYourOwn={true}
/>
)}
</>
);
};
export { BrowsePage as default, BrowsePage };

View File

@@ -1,60 +0,0 @@
import variables from 'config/variables';
import { MdLibraryAdd } from 'react-icons/md';
import { Button } from 'components/Elements';
import Items from '../components/Items/Items';
const CollectionPage = ({
collectionName,
collectionTitle,
collectionDescription,
collectionImg,
items,
busy,
onInstallCollection,
onItemClick,
onSortChange,
}) => {
return (
<>
<div
className="collectionPage"
style={{
backgroundImage: `linear-gradient(to bottom, transparent, black), url('${collectionImg}')`,
}}
>
<div className="nice-tag">{variables.getMessage('modals.main.marketplace.collection')}</div>
<div className="content">
<span className="mainTitle">{collectionTitle}</span>
<span className="subtitle">{collectionDescription}</span>
</div>
<Button
type="collection"
onClick={onInstallCollection}
disabled={busy}
icon={<MdLibraryAdd />}
label={
busy
? variables.getMessage('modals.main.marketplace.installing')
: variables.getMessage('modals.main.marketplace.add_all')
}
/>
</div>
<Items
filterOptions={true}
items={items}
collection={null}
onCollection={true}
toggleFunction={onItemClick}
collectionFunction={() => {}}
filter={''}
onSortChange={onSortChange}
showCreateYourOwn={false}
/>
</>
);
};
export { CollectionPage as default, CollectionPage };

View File

@@ -1,35 +0,0 @@
import variables from 'config/variables';
import { MdOutlineExtensionOff } from 'react-icons/md';
import { Button } from 'components/Elements';
const Create = () => {
return (
<>
<div className="flexTopMarketplace">
<span className="mainTitle">
{variables.getMessage('modals.main.addons.create.title')}
</span>
</div>
<div className="emptyItems">
<div className="emptyNewMessage">
<MdOutlineExtensionOff />
<span className="title">
{variables.getMessage('modals.main.addons.create.moved_title')}
</span>
<span className="subtitle">
{variables.getMessage('modals.main.addons.create.moved_description')}
</span>
<div className="createButtons">
<Button
type="settings"
label={variables.getMessage('modals.main.addons.create.moved_button')}
/>
</div>
</div>
</div>
</>
);
};
export { Create as default, Create };

View File

@@ -1,423 +0,0 @@
import variables from 'config/variables';
import { useState, Fragment } from 'react';
import { toast } from 'react-toastify';
import { MdIosShare, MdFlag, MdAccountCircle } from 'react-icons/md';
import Modal from 'react-modal';
import { Button } from 'components/Elements';
import { install, uninstall } from 'utils/marketplace';
import { ShareModal } from 'components/Elements';
import placeholderIcon from 'assets/icons/marketplace-placeholder.png';
import { Items } from '../components/Items/Items';
// Tab components
import OverviewTab from './components/OverviewTab';
import QuotesTab from './components/QuotesTab';
import PhotosTab from './components/PhotosTab';
import PresetsTab from './components/PresetsTab';
// Helper components
import InfoItem from './components/InfoItem';
import WarningBanner from './components/WarningBanner';
const ItemPage = (props) => {
const [showUpdateButton, setShowUpdateButton] = useState(
props.addonInstalled === true && props.addonInstalledVersion !== props.data.version,
);
const [shareModal, setShareModal] = useState(false);
const [count, setCount] = useState(5);
const [activeTab, setActiveTab] = useState('overview');
const updateAddon = () => {
uninstall(props.data.type, props.data.display_name);
install(props.data.type, props.data);
toast(variables.getMessage('toasts.updated'));
setShowUpdateButton(false);
};
const incrementCount = (type) => {
const data = props.data.data;
let length;
if (type === 'quotes' && Array.isArray(data.quotes)) {
length = data.quotes.length;
} else if (type === 'settings' && data.settings) {
length = Object.keys(data.settings).length;
} else {
return;
}
const newCount = count !== length ? length : 5;
setCount(newCount);
};
const getName = (name) => {
const nameMappings = {
photos: 'photo_packs',
quotes: 'quote_packs',
settings: 'preset_settings',
};
return nameMappings[name] || name;
};
const locale = localStorage.getItem('language');
const shortLocale = locale.includes('_') ? locale.split('_')[0] : locale;
const languageNames = new Intl.DisplayNames([shortLocale], { type: 'language' });
// Extract colour from data (British spelling as used in API)
const mainColor = props.data.data.colour;
// Helper function to determine if a color is light or dark
const isLightColor = (hexColor) => {
if (!hexColor) return false;
// Remove # if present
const hex = hexColor.replace('#', '');
// Convert to RGB
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
// Calculate relative luminance (perceived brightness)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.6; // If > 0.6, it's a light color
};
const isLight = isLightColor(mainColor);
const textColor = isLight ? '#000000' : '#ffffff';
const quotes = Array.isArray(props.data.data.quotes) ? props.data.data.quotes : [];
const photos = Array.isArray(props.data.data.photos) ? props.data.data.photos : [];
const settings = props.data.data.settings;
const hasPhotos = photos.length > 0;
const hasQuotes = quotes.length > 0;
const hasSettings = !!settings;
// Format date for details section
let formattedDate = '';
if (props.data.data.updated_at) {
const dateObj = new Date(props.data.data.updated_at);
formattedDate = new Intl.DateTimeFormat(shortLocale, {
year: 'numeric',
month: 'long',
day: '2-digit',
}).format(dateObj);
}
// Create dynamic styles for theming with the main color
const themedStyles = mainColor ? (
<style>{`
/* Icon buttons styling */
.iconButtons .btn-icon {
background: ${mainColor} !important;
background-image: none !important;
border-color: ${mainColor} !important;
box-shadow: 0 0 0 1px ${mainColor}, 0 4px 12px ${mainColor}40 !important;
color: ${textColor} !important;
}
.iconButtons .btn-icon:hover {
background: ${mainColor} !important;
filter: brightness(${isLight ? '0.95' : '1.15'});
transform: translateY(-2px);
box-shadow: 0 0 0 1px ${mainColor}, 0 6px 20px ${mainColor}60 !important;
}
/* ItemInfo background gradient */
.itemInfo {
background: linear-gradient(135deg, ${mainColor}ee 0%, ${mainColor}aa 50%, ${mainColor}66 100%) !important;
box-shadow: 0 8px 32px ${mainColor}40 !important;
}
/* Icon shadow */
.itemInfo .icon {
box-shadow: 0 8px 32px ${mainColor}80, 0 0 0 1px ${mainColor}40 !important;
}
/* Install button styling */
.itemInfo .installButton {
background: ${mainColor} !important;
background-image: linear-gradient(135deg, ${mainColor} 0%, ${mainColor}dd 100%) !important;
box-shadow: 0 4px 16px ${mainColor}60 !important;
}
.itemInfo .installButton:hover {
background: ${mainColor} !important;
filter: brightness(${isLight ? '0.95' : '1.15'});
box-shadow: 0 6px 24px ${mainColor}80 !important;
}
/* Install button text and icon color */
.itemInfo .installButton span,
.itemInfo .installButton svg {
color: ${textColor} !important;
}
/* Mue logo - circle matches text color, paths match button color */
.itemInfo .installButton .mueLogo circle {
fill: ${textColor} !important;
}
.itemInfo .installButton .mueLogo path {
fill: ${mainColor} !important;
}
/* Remove the default gradient animation when themed */
.itemInfo .installButton.installed {
background: ${mainColor}aa !important;
background-image: none !important;
}
.itemInfo .installButton.installed:hover {
background: ${mainColor}99 !important;
}
`}</style>
) : null;
if (!props.data.display_name) {
return null;
}
let updateButton;
if (showUpdateButton) {
updateButton = (
<Fragment key="update">
<Button
type="settings"
onClick={() => updateAddon()}
label={variables.getMessage('modals.main.addons.product.buttons.update_addon')}
/>
</Fragment>
);
}
// prevent console error
let iconsrc = props.data.icon;
if (!props.data.icon) {
iconsrc = null;
}
return (
<>
<Modal
closeTimeoutMS={300}
isOpen={shareModal}
className="Modal mainModal"
overlayClassName="Overlay"
ariaHideApp={false}
onRequestClose={() => setShareModal(false)}
>
<ShareModal
data={variables.constants.API_URL + '/marketplace/share/' + btoa(props.data.api_name)}
modalClose={() => setShareModal(false)}
/>
</Modal>
{/* <Header
title={
props.addons
? variables.getMessage('modals.main.addons.added')
: props.data.onCollection && props.data.data.in_collections?.length > 0
? props.data.data.in_collections[0].display_name
: variables.getMessage('modals.main.navbar.marketplace')
}
secondaryTitle={
props.data.data.sideload
? props.data.data.name
: props.data.data.display_name
}
report={false}
goBack={props.toggleFunction}
/> */}
<div className="itemPage">
<aside className="itemInfo">
<div className="front">
<img
className="icon"
alt="icon"
draggable={false}
src={props.data.data.icon_url}
onError={(e) => {
e.target.onerror = null;
e.target.src = placeholderIcon;
}}
/>
{localStorage.getItem('welcomePreview') !== 'true' ? (
<>
{props.button}
{updateButton}
</>
) : (
<p style={{ textAlign: 'center' }}>
{variables.getMessage('modals.main.marketplace.product.buttons.not_available_preview')}
</p>
)}
{props.data.data.sideload !== true && (
<>
{themedStyles}
<div className="iconButtons">
<Button
type="icon"
onClick={() => setShareModal(true)}
icon={<MdIosShare />}
tooltipTitle={variables.getMessage('widgets.quote.share')}
tooltipKey="share"
/>
<Button
type="icon"
onClick={() =>
window.open(
variables.constants.REPORT_ITEM +
props.data.data.display_name.split(' ').join('+'),
'_blank',
)
}
icon={<MdFlag />}
tooltipTitle={variables.getMessage('modals.main.marketplace.product.buttons.report')}
tooltipKey="report"
/>
</div>
</>
)}
{props.data.data.in_collections?.length > 0 && (
<div className="inCollection">
<span className="subtitle">
{variables.getMessage('modals.main.marketplace.product.part_of')}
</span>
<span
className="title"
onClick={() =>
props.toggleFunction('collection', props.data.data.in_collections[0].name)
}
>
{props.data.data.in_collections[0].display_name}
</span>
</div>
)}
</div>
</aside>
<div className="itemContent">
<div className="itemTop">
<div className="subHeader">
<InfoItem
icon={<MdAccountCircle />}
header={variables.getMessage('modals.main.marketplace.product.created_by')}
text={props.data.author}
/>
<WarningBanner data={props.data.data} shortLocale={shortLocale} />
</div>
<div className="itemTabs">
<button
type="button"
className={`itemTab ${activeTab === 'overview' ? 'active' : ''}`}
onClick={() => setActiveTab('overview')}
>
{variables.getMessage('modals.main.marketplace.product.overview_tab') || 'Overview'}
</button>
{hasQuotes && (
<button
type="button"
className={`itemTab ${activeTab === 'quotes' ? 'active' : ''}`}
onClick={() => setActiveTab('quotes')}
>
{variables.getMessage('modals.main.marketplace.product.quotes_tab') || 'Quotes'}
</button>
)}
{hasPhotos && (
<button
type="button"
className={`itemTab ${activeTab === 'photos' ? 'active' : ''}`}
onClick={() => setActiveTab('photos')}
>
{variables.getMessage('modals.main.marketplace.product.photos_tab') || 'Photos'}
</button>
)}
{hasSettings && (
<button
type="button"
className={`itemTab ${activeTab === 'presets' ? 'active' : ''}`}
onClick={() => setActiveTab('presets')}
>
{variables.getMessage('modals.main.marketplace.product.presets_tab') || 'Presets'}
</button>
)}
</div>
</div>
<div className="tabContent">
{activeTab === 'overview' && (
<OverviewTab
data={props.data.data}
description={props.data.description}
iconsrc={iconsrc}
shortLocale={shortLocale}
languageNames={languageNames}
formattedDate={formattedDate}
getName={getName}
count={count}
onIncrementCount={(type) => incrementCount(type)}
/>
)}
{activeTab === 'quotes' && hasQuotes && (
<QuotesTab
quotes={quotes}
count={count}
onIncrementCount={(type) => incrementCount(type)}
/>
)}
{activeTab === 'photos' && hasPhotos && <PhotosTab photos={photos} />}
{activeTab === 'presets' && hasSettings && (
<PresetsTab
settings={settings}
count={count}
onIncrementCount={(type) => incrementCount(type)}
/>
)}
</div>
</div>
</div>
{/* {moreByCurator.length > 1 && (
<div className="moreFromCurator">
<span className="title">
{variables.getMessage('modals.main.marketplace.product.more_from_curator', {
name: props.data.author,
})}
</span>
<div>
<Items
isCurator={true}
type={props.data.data.type}
items={moreByCurator}
onCollection={state.collection}
toggleFunction={(input) => props.toggleFunction('item', input)}
collectionFunction={(input) => props.toggleFunction('collection', input)}
filter={''}
moreByCreator={true}
showCreateYourOwn={false}
/>
</div>
</div>
)} */}
{props.relatedItems && props.relatedItems.length > 0 && (
<div className="moreFromCurator">
<span className="title">
{variables.getMessage('modals.main.marketplace.you_might_also_like') ||
'You might also like'}
</span>
<div>
<Items
type={props.data.data.type}
items={props.relatedItems}
onCollection={false}
toggleFunction={(input) => props.toggleFunction('item', input)}
collectionFunction={(input) => props.toggleFunction('collection', input)}
filter={''}
moreByCreator={false}
showCreateYourOwn={false}
/>
</div>
</div>
)}
</>
);
};
export { ItemPage as default, ItemPage };

View File

@@ -1,11 +0,0 @@
const InfoItem = ({ icon, header, text }) => (
<div className="infoItem">
{icon}
<div className="text">
<span className="header">{header}</span>
<span>{text}</span>
</div>
</div>
);
export default InfoItem;

View File

@@ -1,134 +0,0 @@
import variables from 'config/variables';
import { MdCalendarMonth, MdFormatQuote, MdImage, MdTranslate, MdStyle } from 'react-icons/md';
import { Carousel } from '../../components/Elements/Carousel';
import placeholderIcon from 'assets/icons/marketplace-placeholder.png';
import InfoItem from './InfoItem';
const OverviewTab = ({
data,
description,
iconsrc,
shortLocale,
languageNames,
formattedDate,
getName,
count,
onIncrementCount,
}) => {
const quotes = Array.isArray(data.quotes) ? data.quotes : [];
const photos = Array.isArray(data.photos) ? data.photos : [];
const hasPhotos = photos.length > 0;
const hasQuotes = quotes.length > 0;
const hasSettings = data.settings;
// Quote statistics
const totalQuotes = hasQuotes ? quotes.length : 0;
const uniqueAuthorsCount = hasQuotes ? new Set(quotes.map((quote) => quote.author)).size : 0;
const averageCharacters = hasQuotes
? Math.round(
quotes.reduce(
(accumulator, quote) => accumulator + (quote.quote ? quote.quote.length : 0),
0,
) / quotes.length,
)
: 0;
const totalQuotesLabel =
variables.getMessage('modals.main.marketplace.product.no_quotes') || 'Total quotes';
const uniqueAuthorsLabel =
variables.getMessage('modals.main.marketplace.product.unique_authors') || 'Unique authors';
const averageCharactersLabel =
variables.getMessage('modals.main.marketplace.product.average_characters') || 'Avg. characters';
const formatNumber = (value) =>
typeof value === 'number' ? value.toLocaleString(shortLocale) : value;
return (
<>
{/* Preview image for settings presets */}
{hasSettings && (
<img
alt="product"
draggable={false}
src={iconsrc}
onError={(e) => {
e.target.onerror = null;
e.target.src = placeholderIcon;
}}
/>
)}
{/* Description */}
<div className="marketplaceDescription">
<span className="title">
{variables.getMessage('modals.main.marketplace.product.description')}
</span>
<span className="subtitle" dangerouslySetInnerHTML={{ __html: description }} />
</div>
{/* Quote pack statistics */}
{hasQuotes && (
<div className="itemHighlights">
<div className="highlightCard">
<span className="highlightLabel">{totalQuotesLabel}</span>
<span className="highlightValue">{formatNumber(totalQuotes)}</span>
</div>
<div className="highlightCard">
<span className="highlightLabel">{uniqueAuthorsLabel}</span>
<span className="highlightValue">{formatNumber(uniqueAuthorsCount)}</span>
</div>
<div className="highlightCard">
<span className="highlightLabel">{averageCharactersLabel}</span>
<span className="highlightValue">{formatNumber(averageCharacters)}</span>
</div>
</div>
)}
{/* Details section */}
<div className="marketplaceDescription marketplaceDetails">
<span className="title">
{variables.getMessage('modals.main.marketplace.product.details')}
</span>
<div className="moreInfo">
{data.updated_at && (
<InfoItem
icon={<MdCalendarMonth />}
header={variables.getMessage('modals.main.marketplace.product.updated_at')}
text={formattedDate}
/>
)}
{hasQuotes && (
<InfoItem
icon={<MdFormatQuote />}
header={variables.getMessage('modals.main.marketplace.product.no_quotes')}
text={quotes.length}
/>
)}
{hasPhotos && (
<InfoItem
icon={<MdImage />}
header={variables.getMessage('modals.main.marketplace.product.no_images')}
text={photos.length}
/>
)}
{hasQuotes && data.language && (
<InfoItem
icon={<MdTranslate />}
header={variables.getMessage('modals.main.settings.sections.language.title')}
text={languageNames.of(data.language)}
/>
)}
<InfoItem
icon={<MdStyle />}
header={variables.getMessage('modals.main.settings.sections.background.type.title')}
text={
variables.getMessage('modals.main.marketplace.' + getName(data.type)) || 'marketplace'
}
/>
</div>
</div>
</>
);
};
export default OverviewTab;

View File

@@ -1,13 +0,0 @@
import { Carousel } from '../../components/Elements/Carousel';
const PhotosTab = ({ photos }) => {
return (
<div className="carousel">
<div className="carousel_container">
<Carousel data={photos} />
</div>
</div>
);
};
export default PhotosTab;

View File

@@ -1,33 +0,0 @@
import variables from 'config/variables';
const PresetsTab = ({ settings, count, onIncrementCount }) => {
return (
<>
<table>
<tbody>
<tr>
<th>{variables.getMessage('modals.main.marketplace.product.setting')}</th>
<th>{variables.getMessage('modals.main.marketplace.product.value')}</th>
</tr>
{Object.entries(settings)
.slice(0, count)
.map(([key, value]) => (
<tr key={key}>
<td>{key}</td>
<td>{value}</td>
</tr>
))}
</tbody>
</table>
<div className="showMoreItems">
<span className="link" onClick={() => onIncrementCount('settings')}>
{count !== Object.keys(settings).length
? variables.getMessage('modals.main.marketplace.product.show_all')
: variables.getMessage('modals.main.marketplace.product.show_less')}
</span>
</div>
</>
);
};
export default PresetsTab;

View File

@@ -1,31 +0,0 @@
import variables from 'config/variables';
const QuotesTab = ({ quotes, count, onIncrementCount }) => {
return (
<>
<table>
<tbody>
<tr>
<th>{variables.getMessage('modals.main.settings.sections.quote.title')}</th>
<th>{variables.getMessage('modals.main.settings.sections.quote.author')}</th>
</tr>
{quotes.slice(0, count).map((quote, index) => (
<tr key={index}>
<td>{quote.quote}</td>
<td>{quote.author}</td>
</tr>
))}
</tbody>
</table>
<div className="showMoreItems">
<span className="link" onClick={() => onIncrementCount('quotes')}>
{count !== quotes.length
? variables.getMessage('modals.main.marketplace.product.show_all')
: variables.getMessage('modals.main.marketplace.product.show_less')}
</span>
</div>
</>
);
};
export default QuotesTab;

View File

@@ -1,32 +0,0 @@
import variables from 'config/variables';
import { MdOutlineWarning } from 'react-icons/md';
const WarningBanner = ({ data, shortLocale }) => {
const template = (message) => (
<div className="itemWarning">
<MdOutlineWarning />
<div className="text">
<span className="header">Warning</span>
<span>{message}</span>
</div>
</div>
);
if (data.sideload === true) {
return template(variables.getMessage('modals.main.marketplace.product.sideload_warning'));
}
if (data.image_api === true) {
return template(variables.getMessage('modals.main.marketplace.product.third_party_api'));
}
if (data.language !== undefined && data.language !== null) {
if (shortLocale !== data.language) {
return template(variables.getMessage('modals.main.marketplace.product.not_in_language'));
}
}
return null;
};
export default WarningBanner;

View File

@@ -1,9 +0,0 @@
// Tab components
export { default as OverviewTab } from './OverviewTab';
export { default as QuotesTab } from './QuotesTab';
export { default as PhotosTab } from './PhotosTab';
export { default as PresetsTab } from './PresetsTab';
// Helper components
export { default as InfoItem } from './InfoItem';
export { default as WarningBanner } from './WarningBanner';

View File

@@ -1,75 +1,239 @@
import variables from 'config/variables'; import variables from 'config/variables';
import { memo } from 'react'; import { MARKETPLACE_URL } from 'config/constants';
import { memo, useEffect, useRef, useState } from 'react';
import { MdOutlineWifiOff } from 'react-icons/md';
import Tabs from 'components/Elements/MainModal/backend/Tabs';
import { useMarketplaceInstall } from 'features/marketplace/components/hooks/useMarketplaceInstall';
import Tabs from '../../../components/Elements/MainModal/backend/Tabs'; function DiscoverContent({ category, onBreadcrumbsChange }) {
import MarketplaceTab from '../../marketplace/views/Browse'; const iframeRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const { installItem, uninstallItem } = useMarketplaceInstall();
function Discover({ // Check for offline mode
changeTab, const offlineMode = localStorage.getItem('offlineMode') === 'true';
deepLinkData, const isOffline = navigator.onLine === false || offlineMode;
currentTab,
onSectionChange, useEffect(() => {
onProductView, // Show loader when category changes
resetToAll, setIsLoading(true);
onResetToAll, // Clear breadcrumbs when navigating to a new category
navigationTrigger, if (onBreadcrumbsChange) {
}) { onBreadcrumbsChange([]);
}
// Update iframe src with category
if (iframeRef.current) {
// Collections use path-based routing, others use query params
if (category === 'collections') {
iframeRef.current.src = `${MARKETPLACE_URL}/collections?embed=true`;
} else {
iframeRef.current.src = `${MARKETPLACE_URL}?embed=true&type=${category}`;
}
}
}, [category, onBreadcrumbsChange]);
useEffect(() => {
// Check for item parameter in URL and update iframe
const checkAndLoadItem = () => {
const hash = window.location.hash;
const urlParams = new URLSearchParams(hash.split('?')[1]);
const itemId = urlParams.get('item');
if (itemId && iframeRef.current) {
setIsLoading(true);
// Get item from localStorage to determine type
const installed = JSON.parse(localStorage.getItem('installed')) || [];
const item = installed.find((i) => i.name === itemId);
if (item) {
// Map item type to URL path
const pathMap = {
photo_packs: 'packs',
quote_packs: 'packs',
preset_settings: 'presets',
};
const pathSegment = pathMap[item.type] || 'packs';
const itemIdToUse = item.id || itemId;
// Navigate to /packs/{id} or /presets/{id}
iframeRef.current.src = `${MARKETPLACE_URL}/${pathSegment}/${itemIdToUse}?embed=true`;
} else {
// Fallback if item not found in localStorage
iframeRef.current.src = `${MARKETPLACE_URL}/packs/${itemId}?embed=true`;
}
}
};
// Check on mount and when category changes
checkAndLoadItem();
// Listen for hash changes and popstate (from history.pushState)
window.addEventListener('hashchange', checkAndLoadItem);
window.addEventListener('popstate', checkAndLoadItem);
return () => {
window.removeEventListener('hashchange', checkAndLoadItem);
window.removeEventListener('popstate', checkAndLoadItem);
};
}, [category]);
useEffect(() => {
// Listen for postMessage events from the iframe
const handleMessage = (event) => {
// Verify the origin if needed
if (event.origin !== MARKETPLACE_URL) {
return;
}
const { type, payload } = event.data;
switch (type) {
case 'marketplace:item:install':
if (payload?.item) {
installItem(payload.item.type, payload.item);
// Send confirmation back to iframe
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: 'marketplace:item:installed',
payload: { id: payload.item.id || payload.item.name, installed: true },
},
MARKETPLACE_URL
);
}
}
break;
case 'marketplace:item:uninstall':
if (payload?.item) {
uninstallItem(payload.item.type, payload.item.display_name || payload.item.name);
// Send confirmation back to iframe
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: 'marketplace:item:installed',
payload: { id: payload.item.id || payload.item.name, installed: false },
},
MARKETPLACE_URL
);
}
}
break;
case 'marketplace:item:check-installed':
if (payload?.id) {
// Check if item is installed
const installed = JSON.parse(localStorage.getItem('installed')) || [];
const isInstalled = installed.some((item) => item.id === payload.id);
// Send status back to iframe
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: 'marketplace:item:installed',
payload: { id: payload.id, installed: isInstalled },
},
MARKETPLACE_URL
);
}
}
break;
case 'marketplace:breadcrumbs':
if (payload?.breadcrumbs && onBreadcrumbsChange) {
onBreadcrumbsChange(payload.breadcrumbs);
}
break;
default:
break;
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [installItem, uninstallItem, onBreadcrumbsChange]);
const handleLoad = () => {
setIsLoading(false);
};
// Show offline error message if offline
if (isOffline) {
return (
<div className="emptyItems">
<div className="emptyMessage">
<MdOutlineWifiOff />
<h1>{variables.getMessage('modals.main.marketplace.offline.title')}</h1>
<p className="description">
{variables.getMessage('modals.main.marketplace.offline.description')}
</p>
</div>
</div>
);
}
return (
<div style={{ position: 'relative', width: '100%', minHeight: '100vh' }}>
{isLoading && (
<div className="loaderHolder" style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10
}}>
<div id="loader"></div>
<span className="subtitle">{variables.getMessage('modals.main.loading')}</span>
</div>
)}
<iframe
ref={iframeRef}
src={category === 'collections' ? `${MARKETPLACE_URL}/collections?embed=true` : `${MARKETPLACE_URL}?embed=true&type=${category}`}
onLoad={handleLoad}
scrolling="no"
style={{
width: '100%',
height: '2000px',
minHeight: '100vh',
border: 'none',
opacity: isLoading ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
overflow: 'hidden',
}}
title="Marketplace"
/>
</div>
);
}
const sections = [
{ label: 'modals.main.marketplace.all', name: 'all' },
{ label: 'modals.main.marketplace.photo_packs', name: 'photo_packs' },
{ label: 'modals.main.marketplace.quote_packs', name: 'quote_packs' },
{ label: 'modals.main.marketplace.preset_settings', name: 'preset_settings' },
{ label: 'modals.main.marketplace.collections', name: 'collections' },
];
function Discover(props) {
return ( return (
<Tabs <Tabs
changeTab={(type) => changeTab(type)} changeTab={(type) => props.changeTab(type)}
current="discover" current="discover"
currentTab={currentTab} currentTab={props.currentTab}
onSectionChange={onSectionChange} onSectionChange={props.onSectionChange}
resetToFirst={resetToAll}
> >
<div label={variables.getMessage('modals.main.marketplace.all')} name="all"> {sections.map(({ label, name }) => (
<MarketplaceTab <div key={name} label={variables.getMessage(label)} name={name}>
type="all" <DiscoverContent category={name} onBreadcrumbsChange={props.onBreadcrumbsChange} />
deepLinkData={deepLinkData} </div>
onProductView={onProductView} ))}
onResetToAll={onResetToAll}
navigationTrigger={navigationTrigger}
/>
</div>
<div label={variables.getMessage('modals.main.marketplace.photo_packs')} name="photo_packs">
<MarketplaceTab
type="photo_packs"
deepLinkData={deepLinkData}
onProductView={onProductView}
onResetToAll={onResetToAll}
navigationTrigger={navigationTrigger}
/>
</div>
<div label={variables.getMessage('modals.main.marketplace.quote_packs')} name="quote_packs">
<MarketplaceTab
type="quote_packs"
deepLinkData={deepLinkData}
onProductView={onProductView}
onResetToAll={onResetToAll}
navigationTrigger={navigationTrigger}
/>
</div>
<div
label={variables.getMessage('modals.main.marketplace.preset_settings')}
name="preset_settings"
>
<MarketplaceTab
type="preset_settings"
deepLinkData={deepLinkData}
onProductView={onProductView}
onResetToAll={onResetToAll}
navigationTrigger={navigationTrigger}
/>
</div>
<div label={variables.getMessage('modals.main.marketplace.collections')} name="collections">
<MarketplaceTab
type="collections"
deepLinkData={deepLinkData}
onProductView={onProductView}
onResetToAll={onResetToAll}
navigationTrigger={navigationTrigger}
/>
</div>
</Tabs> </Tabs>
); );
} }

View File

@@ -3,7 +3,6 @@ import { memo } from 'react';
import Tabs from 'components/Elements/MainModal/backend/Tabs'; import Tabs from 'components/Elements/MainModal/backend/Tabs';
import Added from '../../marketplace/views/Added'; import Added from '../../marketplace/views/Added';
import Create from '../../marketplace/views/Create';
function Library(props) { function Library(props) {
return ( return (
@@ -17,7 +16,6 @@ function Library(props) {
<Added /> <Added />
</div> </div>
<div label={variables.getMessage('modals.main.addons.create.title')} name="create"> <div label={variables.getMessage('modals.main.addons.create.title')} name="create">
<Create />
</div> </div>
</Tabs> </Tabs>
); );