diff --git a/src/components/Elements/MainModal/scss/marketplace/_main.scss b/src/components/Elements/MainModal/scss/marketplace/_main.scss index 1a3e8df2..27eec856 100644 --- a/src/components/Elements/MainModal/scss/marketplace/_main.scss +++ b/src/components/Elements/MainModal/scss/marketplace/_main.scss @@ -299,7 +299,7 @@ border-radius: 12px; box-shadow: 0 5px 25px black; aspect-ratio: 1 / 1; - object-fit: contain; + object-fit: cover; } .divider { diff --git a/src/features/marketplace/contexts/MarketplaceContext.jsx b/src/features/marketplace/contexts/MarketplaceContext.jsx new file mode 100644 index 00000000..c98654db --- /dev/null +++ b/src/features/marketplace/contexts/MarketplaceContext.jsx @@ -0,0 +1,13 @@ +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 }; diff --git a/src/features/marketplace/hooks/useMarketplaceData.js b/src/features/marketplace/hooks/useMarketplaceData.js new file mode 100644 index 00000000..7fa9ca98 --- /dev/null +++ b/src/features/marketplace/hooks/useMarketplaceData.js @@ -0,0 +1,104 @@ +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, + }; +}; diff --git a/src/features/marketplace/hooks/useMarketplaceInstall.js b/src/features/marketplace/hooks/useMarketplaceInstall.js new file mode 100644 index 00000000..2252f190 --- /dev/null +++ b/src/features/marketplace/hooks/useMarketplaceInstall.js @@ -0,0 +1,71 @@ +import { useState, useRef } from 'react'; +import { toast } from 'react-toastify'; +import variables from 'config/variables'; +import { install, uninstall } from 'utils/marketplace'; + +const API_V2_BASE = `${variables.constants.API_URL}/marketplace`; + +export const useMarketplaceInstall = () => { + const [busy, setBusy] = useState(false); + const controllerRef = useRef(new AbortController()); + + const installItem = (type, data) => { + install(type, data); + toast(variables.getMessage('toasts.installed')); + variables.stats.postEvent('marketplace-item', `${data.display_name || data.name} installed`); + variables.stats.postEvent('marketplace', 'Install'); + }; + + const uninstallItem = (type, name) => { + uninstall(type, name); + toast(variables.getMessage('toasts.uninstalled')); + variables.stats.postEvent('marketplace-item', `${name} uninstalled`); + variables.stats.postEvent('marketplace', 'Uninstall'); + }; + + const installCollection = async (items) => { + setBusy(true); + try { + const installed = JSON.parse(localStorage.getItem('installed')) || []; + + for (const item of items) { + // Skip if already installed + if (installed.some((i) => i.name === item.display_name)) { + continue; + } + + // Fetch full item data + const itemEndpoint = item.id + ? `${API_V2_BASE}/item/${item.id}` + : `${API_V2_BASE}/item/${item.type}/${item.name}`; + + const response = await fetch(itemEndpoint, { + signal: controllerRef.current.signal, + }); + const { data } = await response.json(); + + // Install item + install(data.type, data, false, true); + variables.stats.postEvent('marketplace-item', `${item.display_name} installed`); + variables.stats.postEvent('marketplace', 'Install'); + } + + toast(variables.getMessage('toasts.installed')); + window.location.reload(); + } catch (error) { + if (!controllerRef.current.signal.aborted) { + console.error('Failed to install collection:', error); + toast(variables.getMessage('toasts.error')); + } + } finally { + setBusy(false); + } + }; + + return { + busy, + installItem, + uninstallItem, + installCollection, + }; +}; diff --git a/src/features/marketplace/hooks/useMarketplaceNavigation.js b/src/features/marketplace/hooks/useMarketplaceNavigation.js new file mode 100644 index 00000000..0497ee25 --- /dev/null +++ b/src/features/marketplace/hooks/useMarketplaceNavigation.js @@ -0,0 +1,223 @@ +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, '
')), + 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, + }; +}; diff --git a/src/features/marketplace/views/Browse.jsx b/src/features/marketplace/views/Browse.jsx index 21c18a1a..4670c342 100644 --- a/src/features/marketplace/views/Browse.jsx +++ b/src/features/marketplace/views/Browse.jsx @@ -1,667 +1,222 @@ import variables from 'config/variables'; -import { PureComponent } from 'react'; -import { toast } from 'react-toastify'; -import { - MdWifiOff, - MdLocalMall, - MdClose, - MdSearch, - MdOutlineArrowForward, - MdLibraryAdd, -} from 'react-icons/md'; +import { useState, useEffect } from 'react'; +import { MdWifiOff, MdLocalMall } from 'react-icons/md'; import ItemPage from './ItemPage'; -import Items from '../components/Items/Items'; -import { Button } from 'components/Elements'; +import CollectionPage from './CollectionPage'; +import BrowsePage from './BrowsePage'; import InstallButton from '../components/Elements/InstallButton'; -import { install, urlParser, uninstall } from 'utils/marketplace'; -import { updateHash } from 'utils/deepLinking'; +import { useMarketplaceData } from '../hooks/useMarketplaceData'; +import { useMarketplaceNavigation } from '../hooks/useMarketplaceNavigation'; +import { useMarketplaceInstall } from '../hooks/useMarketplaceInstall'; +import { MarketplaceContext } from '../contexts/MarketplaceContext'; -// API v2 base URL -const API_V2_BASE = `${variables.constants.API_URL}/marketplace`; +const Marketplace = ({ type, onProductView, onResetToAll, deepLinkData, navigationTrigger }) => { + const [filter, setFilter] = useState(''); -class Marketplace extends PureComponent { - constructor(props) { - super(props); - this.state = { - items: [], - relatedItems: [], - button: '', - done: false, - item: {}, - collection: false, - filter: '', - }; - this.buttons = { - uninstall: ( - this.manage('uninstall')} - isInstalled={true} - label={variables.getMessage('modals.main.marketplace.product.buttons.remove')} - /> - ), - install: ( - this.manage('install')} - isInstalled={false} - label={variables.getMessage('modals.main.marketplace.product.buttons.addtomue')} - /> - ), - }; - this.controller = new AbortController(); - } + // 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(); - async toggle(pageType, data) { - if (pageType === 'item') { - let info; - let relatedItems = []; + const handleManage = (action) => { + if (!currentItem) return; - // get item info using API v2 - try { - let type = this.props.type; - if (type === 'all' || type === 'collections') { - type = data.type; - } - - // API v2: Fetch by ID if available, otherwise by name - const itemEndpoint = data.id - ? `${API_V2_BASE}/item/${data.id}` - : `${API_V2_BASE}/item/${type}/${data.name}`; - - info = await ( - await fetch(itemEndpoint, { - signal: this.controller.signal, - }) - ).json(); - - // Fetch related items using API v2 - if (info.data?.id) { - try { - const relatedResponse = await fetch(`${API_V2_BASE}/item/${info.data.id}/related`, { - signal: this.controller.signal, - }); - const relatedData = await relatedResponse.json(); - relatedItems = relatedData.data?.related || []; - } catch (relatedError) { - console.warn('Failed to fetch related items:', relatedError); - } - - // Track view using API v2 - fetch(`${API_V2_BASE}/item/${info.data.id}/view`, { - method: 'POST', - signal: this.controller.signal, - }).catch(() => {}); // Silent fail for analytics - } - } catch (error) { - // Error caught but only used for flow control - if (this.controller.signal.aborted === false) { - console.error('Failed to fetch item:', error); - toast(variables.getMessage('toasts.error')); - } - return; - } - - if (this.controller.signal.aborted === true) { - return; - } - - // Verify we have valid data before proceeding - if (!info || !info.data) { - console.error('Invalid item data received:', info); - toast(variables.getMessage('toasts.error')); - return; - } - - // check if already installed - let button = this.buttons.install; - let addonInstalled = false; - let addonInstalledVersion; - - const installed = JSON.parse(localStorage.getItem('installed')) || []; - - if (installed.some((item) => item.name === info.data.name)) { - button = this.buttons.uninstall; - addonInstalled = true; - for (let i = 0; i < installed.length; i++) { - if (installed[i].name === info.data.name) { - addonInstalledVersion = installed[i].version; - break; - } - } - } - - this.setState({ - item: { - id: info.data.id, // Store item ID for deep linking - 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, '
')), - version: info.data.version, - icon: info.data.screenshot_url, - data: info.data, - addonInstalled, - addonInstalledVersion, - api_name: data.name, - }, - relatedItems, - button: button, - }); - - // Update URL hash with item ID for deep linking - if (info.data?.id) { - // If viewing from a collection, include collection in URL - if (this.state.collection && this.state.collectionName) { - updateHash(`#discover/collection/${this.state.collectionName}/${info.data.id}`); - } else { - updateHash(`#discover/${info.data.type}/${info.data.id}`); - } - } - - // Notify parent about product view for breadcrumbs - if (this.props.onProductView) { - const productViewData = { - id: info.data.id, - type: info.data.type, - name: info.data.display_name || info.data.name, - fromCollection: this.state.collection, - collectionName: this.state.collectionName, - collectionTitle: this.state.collectionTitle, - collectionDescription: this.state.collectionDescription, - collectionImg: this.state.collectionImg, - onBack: () => this.toggle('main'), - onBackToAll: () => { - // Clear collection state before toggling to main - this.setState( - { - collection: false, - collectionName: null, - collectionTitle: null, - collectionDescription: null, - collectionImg: null, - }, - () => { - // Call after state is updated - updateHash('#discover/all'); - this.toggle('main'); - if (this.props.onResetToAll) { - this.props.onResetToAll(); - } - }, - ); - }, - }; - console.log('Product view data being sent to parent:', productViewData); - this.props.onProductView(productViewData); - } - - document.querySelector('#modal').scrollTop = 0; - variables.stats.postEvent('marketplace-item', `${this.state.item.display_name} viewed`); - } else if (pageType === 'collection') { - this.setState({ done: false, item: {} }); - // Use API v2 for collections - const collection = await ( - await fetch(`${API_V2_BASE}/collection/${data}`, { - signal: this.controller.signal, - }) - ).json(); - - const collectionState = { - items: collection.data.items, - collectionName: data, // Store the collection name/slug for navigation - collectionTitle: collection.data.display_name, - collectionDescription: collection.data.description, - collectionImg: collection.data.img, - collection: true, - done: true, - }; - console.log('Setting collection state:', collectionState); - this.setState(collectionState); - - // Update hash for collection deep linking - updateHash(`#discover/collection/${data}`); - - // Notify parent about collection view for breadcrumbs - if (this.props.onProductView) { - const collectionViewData = { - id: data, - type: 'collection', - name: collection.data.display_name, - isCollection: true, - fromCollection: false, - collectionName: data, - collectionTitle: collection.data.display_name, - onBack: () => { - this.setState( - { - collection: false, - collectionName: null, - collectionTitle: null, - collectionDescription: null, - collectionImg: null, - }, - () => { - // Call toggle after state is updated - updateHash('#discover/all'); - this.toggle('main'); - }, - ); - }, - onBackToAll: () => { - // Clear collection state and return to main view - this.setState( - { - collection: false, - collectionName: null, - collectionTitle: null, - collectionDescription: null, - collectionImg: null, - }, - () => { - // Call toggle after state is updated - updateHash('#discover/all'); - this.toggle('main'); - if (this.props.onResetToAll) { - this.props.onResetToAll(); - } - }, - ); - }, - }; - console.log('Collection view data being sent to parent:', collectionViewData); - this.props.onProductView(collectionViewData); - } + if (action === 'install') { + installItem(currentItem.type, currentItem.data); } else { - this.setState({ item: {}, relatedItems: [] }); - - // Update hash based on current view state - if (this.state.collection && this.state.collectionName) { - // If returning to a collection view, preserve collection hash - updateHash(`#discover/collection/${this.state.collectionName}`); - } else { - // Clear hash when returning to main view - updateHash(`#discover/${this.props.type}`); - } - - // Clear product view for breadcrumbs - if (this.props.onProductView) { - this.props.onProductView(null); - } + uninstallItem(currentItem.type, currentItem.display_name); } - } + }; - async getItems() { - this.setState({ done: false }); + // Install/uninstall buttons + const buttons = { + uninstall: ( + handleManage('uninstall')} + isInstalled={true} + label={variables.getMessage('modals.main.marketplace.product.buttons.remove')} + /> + ), + install: ( + handleManage('install')} + isInstalled={false} + label={variables.getMessage('modals.main.marketplace.product.buttons.addtomue')} + /> + ), + }; - // Use API v2 endpoints - const dataURL = - this.props.type === 'collections' - ? `${API_V2_BASE}/collections` - : `${API_V2_BASE}/items/${this.props.type}`; + // Get appropriate button based on installation status + const getButton = () => { + if (!currentItem) return buttons.install; + return currentItem.addonInstalled ? buttons.uninstall : buttons.install; + }; - const { data } = await (await fetch(dataURL, { signal: this.controller.signal })).json(); - const collections = await ( - await fetch(`${API_V2_BASE}/collections`, { - signal: this.controller.signal, - }) - ).json(); + // Handle deep linking on mount + useEffect(() => { + if (deepLinkData) { + const { itemId, collection, category } = deepLinkData; - if (this.controller.signal.aborted === true) { - return; - } - - const sorted = this.sortMarketplace(data, false); - - this.setState({ - items: sorted.items, - sortType: sorted.sortType, - oldItems: sorted.items, - collections: collections.data, - displayedCollection: - collections.data[Math.floor(Math.random() * collections.data.length)] || [], - done: true, - }); - } - - manage(type) { - if (type === 'install') { - install(this.state.item.type, this.state.item.data); - } else { - uninstall(this.state.item.type, this.state.item.display_name); - } - - toast(variables.getMessage('toasts.' + type + 'ed')); - this.setState({ button: type === 'install' ? this.buttons.uninstall : this.buttons.install }); - - variables.stats.postEvent( - 'marketplace-item', - `${this.state.item.display_name} ${type === 'install' ? 'installed' : 'uninstalled'}`, - ); - variables.stats.postEvent('marketplace', type === 'install' ? 'Install' : 'Uninstall'); - } - - async installCollection() { - this.setState({ busy: true }); - try { - const installed = JSON.parse(localStorage.getItem('installed')); - for (const item of this.state.items) { - if (installed.some((i) => i.name === item.display_name)) continue; // don't install if already installed - - // Use API v2 - fetch by ID if available, otherwise by name - const itemEndpoint = item.id - ? `${API_V2_BASE}/item/${item.id}` - : `${API_V2_BASE}/item/${item.type}/${item.name}`; - - const { data } = await ( - await fetch(itemEndpoint, { - signal: this.controller.signal, - }) - ).json(); - - install(data.type, data, false, true); - variables.stats.postEvent('marketplace-item', `${item.display_name} installed}`); - variables.stats.postEvent('marketplace', 'Install'); - } - toast(variables.getMessage('toasts.installed')); - window.location.reload(); - } catch (error) { - console.error(error); - toast(variables.getMessage('toasts.error')); - } finally { - this.setState({ busy: false }); - } - } - - sortMarketplace(data, sendEvent) { - const value = localStorage.getItem('sortMarketplace') || 'a-z'; - let items = data || this.state.items; - if (!items) { - return; - } - - switch (value) { - case 'a-z': { - // sort by name key alphabetically - const sorted = items.sort((a, b) => { - if (a.display_name < b.display_name) { - return -1; - } - if (a.display_name > b.display_name) { - return 1; - } - return 0; - }); - items = sorted; - break; - } - case 'z-a': - items.sort(); - items.reverse(); - break; - default: - break; - } - - if (sendEvent) { - variables.stats.postEvent('marketplace', 'Sort'); - } - - return { items: items, sortType: value }; - } - - changeSort(value) { - localStorage.setItem('sortMarketplace', value); - this.setState(this.sortMarketplace(null, true)); - } - - returnToMain() { - this.setState({ - items: this.state.oldItems, - collection: false, - collectionName: null, - collectionTitle: null, - collectionDescription: null, - collectionImg: null, - }); - updateHash(`#discover/${this.props.type}`); - } - - componentDidMount() { - if (navigator.onLine === false || localStorage.getItem('offlineMode') === 'true') { - return; - } - - this.getItems(); - - // Handle deep link data if provided - if (this.props.deepLinkData) { - const { itemId, collection, category } = this.props.deepLinkData; - - // Wait for items to load, then open the specific item or collection setTimeout(() => { if (collection) { - // Open collection - this.toggle('collection', collection); + navigateToCollection(collection); } else if (itemId) { - // Open specific item by ID - this.toggle('item', { id: itemId, type: category }); + navigateToItem({ id: itemId, type: category }, type); } }, 500); } - } + }, [deepLinkData, navigateToCollection, navigateToItem, type]); - componentDidUpdate(prevProps) { - // Handle navigation trigger changes - if ( - this.props.navigationTrigger && - this.props.navigationTrigger !== prevProps.navigationTrigger - ) { - const { type, data } = this.props.navigationTrigger; + // Handle navigation trigger changes + useEffect(() => { + if (navigationTrigger) { + const { type: navType, data } = navigationTrigger; - if (type === 'product' && data) { - // Navigate to product - this.toggle('item', { id: data.id, type: data.type }); - } else if (type === 'collection' && data) { - // Navigate to collection - this.toggle('collection', data); - } else if (type === 'main' || type === 'section') { - // Navigate to main view (browse/listing page) - // Check if we need to clear collection state first - if (data && data.clearCollection && this.state.collection) { - this.setState( - { - collection: false, - collectionName: null, - collectionTitle: null, - collectionDescription: null, - collectionImg: null, - }, - () => { - this.toggle('main'); - }, - ); - } else { - this.toggle('main'); - } + 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]); - componentWillUnmount() { - // stop making requests - this.controller.abort(); - } + // Context value to share with child components + const contextValue = { + currentView, + currentItem, + currentCollection, + items, + navigateToItem: (data) => navigateToItem(data, type), + navigateToCollection, + navigateBack, + navigateToMain, + installItem, + uninstallItem, + }; - render() { - const errorMessage = (msg) => { - return ( - <> -
- - {variables.getMessage('modals.main.navbar.marketplace')} - -
-
-
{msg}
-
- - ); - }; + // Error message component + const renderError = (content) => ( + <> +
+ {variables.getMessage('modals.main.navbar.marketplace')} +
+
+
{content}
+
+ + ); - if (navigator.onLine === false || localStorage.getItem('offlineMode') === 'true') { - return errorMessage( - <> - - - {variables.getMessage('modals.main.marketplace.offline.title')} - - - {variables.getMessage('modals.main.marketplace.offline.description')} - - , - ); - } - - if (this.state.done === false) { - return errorMessage( -
-
- {variables.getMessage('modals.main.loading')} -
, - ); - } - - if (!this.state.items || this.state.items?.length === 0) { - this.getItems(); - return errorMessage( - <> - - {variables.getMessage('modals.main.addons.empty.title')} - - {variables.getMessage('modals.main.marketplace.no_items')} - - , - ); - } - - if (this.state.item.display_name) { - return ( - this.toggle(...args)} - addonInstalled={this.state.item.addonInstalled} - addonInstalledVersion={this.state.item.addonInstalledVersion} - icon={this.state.item.screenshot_url} - relatedItems={this.state.relatedItems} - /> - ); - } - - return ( + // Offline check + if (navigator.onLine === false || localStorage.getItem('offlineMode') === 'true') { + return renderError( <> - {this.state.collection === true ? ( - <> -
-
- {variables.getMessage('modals.main.marketplace.collection')} -
-
- {this.state.collectionTitle} - {this.state.collectionDescription} -
- -
- - ) : ( - <> - {/*
- - {variables.getMessage('modals.main.navbar.marketplace')} - -
*/} -
- {this.props.type !== 'collections' && ( -
-
- this.setState({ filter: event.target.value })} - /> - - -
- )} -
- - )} - {this.props.type === 'collections' && !this.state.collection ? ( - this.state.items.map((item) => - !item.news ? ( -
-
- {item.display_name} - {item.description} -
-
- ) : null, - ) - ) : ( - this.toggle('item', input)} - collectionFunction={(input) => this.toggle('collection', input)} - filter={this.state.filter} - onSortChange={(value) => this.changeSort(value)} - showCreateYourOwn={true} - /> - )} - + + + {variables.getMessage('modals.main.marketplace.offline.title')} + + + {variables.getMessage('modals.main.marketplace.offline.description')} + + , ); } -} + + // Loading state + if (loading) { + return renderError( +
+
+ {variables.getMessage('modals.main.loading')} +
, + ); + } + + // Empty state + if (!items || items.length === 0) { + return renderError( + <> + + {variables.getMessage('modals.main.addons.empty.title')} + + {variables.getMessage('modals.main.marketplace.no_items')} + + , + ); + } + + // Render item view + if (currentView === 'item' && currentItem) { + return ( + + { + 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} + /> + + ); + } + + // Render collection view + if (currentView === 'collection' && currentCollection) { + return ( + + installCollection(currentCollection.items)} + onItemClick={(item) => navigateToItem(item, type)} + onSortChange={changeSort} + /> + + ); + } + + // Render browse view + return ( + + setFilter(event.target.value)} + onItemClick={(item) => navigateToItem(item, type)} + onCollectionClick={navigateToCollection} + onSortChange={changeSort} + /> + + ); +}; export default Marketplace; diff --git a/src/features/marketplace/views/BrowsePage.jsx b/src/features/marketplace/views/BrowsePage.jsx new file mode 100644 index 00000000..b4ca5ac2 --- /dev/null +++ b/src/features/marketplace/views/BrowsePage.jsx @@ -0,0 +1,83 @@ +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 ( + <> +
+ {type !== 'collections' && ( +
+
+ + + +
+ )} +
+ + {type === 'collections' ? ( + items.map((item) => + !item.news ? ( +
+
+ {item.display_name} + {item.description} +
+
+ ) : null, + ) + ) : ( + + )} + + ); +}; + +export { BrowsePage as default, BrowsePage }; diff --git a/src/features/marketplace/views/CollectionPage.jsx b/src/features/marketplace/views/CollectionPage.jsx new file mode 100644 index 00000000..226f7c99 --- /dev/null +++ b/src/features/marketplace/views/CollectionPage.jsx @@ -0,0 +1,60 @@ +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 ( + <> +
+
{variables.getMessage('modals.main.marketplace.collection')}
+
+ {collectionTitle} + {collectionDescription} +
+ +
+ + {}} + filter={''} + onSortChange={onSortChange} + showCreateYourOwn={false} + /> + + ); +}; + +export { CollectionPage as default, CollectionPage }; diff --git a/src/features/marketplace/views/ItemPage.jsx b/src/features/marketplace/views/ItemPage.jsx index ac404e3b..1f25181d 100644 --- a/src/features/marketplace/views/ItemPage.jsx +++ b/src/features/marketplace/views/ItemPage.jsx @@ -1,5 +1,5 @@ import variables from 'config/variables'; -import { PureComponent, Fragment } from 'react'; +import { useState, Fragment } from 'react'; import { toast } from 'react-toastify'; import { MdIosShare, MdFlag, MdAccountCircle } from 'react-icons/md'; import Modal from 'react-modal'; @@ -21,48 +21,23 @@ import PresetsTab from './components/PresetsTab'; import InfoItem from './components/InfoItem'; import WarningBanner from './components/WarningBanner'; -class ItemPage extends PureComponent { - constructor(props) { - super(props); - this.state = { - showUpdateButton: - this.props.addonInstalled === true && - this.props.addonInstalledVersion !== this.props.data.version, - shareModal: false, - count: 5, - moreByCurator: [], - activeTab: 'overview', - }; - } +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'); - async getCurator(name) { - try { - const { data } = await ( - await fetch(`${variables.constants.API_URL}/marketplace/curator/${name}`) - ).json(); - this.setState({ - moreByCurator: data.items, - }); - } catch (e) { - console.error(e); - } - } - - componentDidMount() { - this.getCurator(this.props.data.author); - } - - updateAddon() { - uninstall(this.props.data.type, this.props.data.display_name); - install(this.props.data.type, this.props.data); + const updateAddon = () => { + uninstall(props.data.type, props.data.display_name); + install(props.data.type, props.data); toast(variables.getMessage('toasts.updated')); - this.setState({ - showUpdateButton: false, - }); - } + setShowUpdateButton(false); + }; - incrementCount(type) { - const data = this.props.data.data; + const incrementCount = (type) => { + const data = props.data.data; let length; if (type === 'quotes' && Array.isArray(data.quotes)) { @@ -73,390 +48,376 @@ class ItemPage extends PureComponent { return; } - const newCount = this.state.count !== length ? length : 5; - this.setState({ count: newCount }); - } + const newCount = count !== length ? length : 5; + setCount(newCount); + }; - getName(name) { + 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); } - render() { - const locale = localStorage.getItem('language'); - const shortLocale = locale.includes('_') ? locale.split('_')[0] : locale; - const languageNames = new Intl.DisplayNames([shortLocale], { type: 'language' }); + // Create dynamic styles for theming with the main color + const themedStyles = mainColor ? ( + + ) : null; - .itemInfo .installButton:hover { - background: ${mainColor} !important; - filter: brightness(${isLight ? '0.95' : '1.15'}); - box-shadow: 0 6px 24px ${mainColor}80 !important; - } + if (!props.data.display_name) { + return null; + } - /* 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; - } - `} - ) : null; - - if (!this.props.data.display_name) { - return null; - } - - let updateButton; - if (this.state.showUpdateButton) { - updateButton = ( - - - {hasQuotes && ( - - )} - - {hasPhotos && ( - - )} - - {hasSettings && ( - - )} - - -
- {activeTab === 'overview' && ( - this.incrementCount(type)} - /> - )} - {activeTab === 'quotes' && hasQuotes && ( - this.incrementCount(type)} - /> - )} - {activeTab === 'photos' && hasPhotos && } - {activeTab === 'presets' && hasSettings && ( - this.incrementCount(type)} - /> - )} -
- - - {/* {moreByCurator.length > 1 && ( -
- - {variables.getMessage('modals.main.marketplace.product.more_from_curator', { - name: this.props.data.author, - })} - -
- this.props.toggleFunction('item', input)} - collectionFunction={(input) => this.props.toggleFunction('collection', input)} - filter={''} - moreByCreator={true} - showCreateYourOwn={false} - /> -
-
- )} */} - {this.props.relatedItems && this.props.relatedItems.length > 0 && ( -
- - {variables.getMessage('modals.main.marketplace.you_might_also_like') || - 'You might also like'} - -
- this.props.toggleFunction('item', input)} - collectionFunction={(input) => this.props.toggleFunction('collection', input)} - filter={''} - moreByCreator={false} - showCreateYourOwn={false} - /> -
-
- )} - + let updateButton; + if (showUpdateButton) { + updateButton = ( + + + {hasQuotes && ( + + )} + + {hasPhotos && ( + + )} + + {hasSettings && ( + + )} + + +
+ {activeTab === 'overview' && ( + incrementCount(type)} + /> + )} + {activeTab === 'quotes' && hasQuotes && ( + incrementCount(type)} + /> + )} + {activeTab === 'photos' && hasPhotos && } + {activeTab === 'presets' && hasSettings && ( + incrementCount(type)} + /> + )} +
+ + + {/* {moreByCurator.length > 1 && ( +
+ + {variables.getMessage('modals.main.marketplace.product.more_from_curator', { + name: props.data.author, + })} + +
+ props.toggleFunction('item', input)} + collectionFunction={(input) => props.toggleFunction('collection', input)} + filter={''} + moreByCreator={true} + showCreateYourOwn={false} + /> +
+
+ )} */} + {props.relatedItems && props.relatedItems.length > 0 && ( +
+ + {variables.getMessage('modals.main.marketplace.you_might_also_like') || + 'You might also like'} + +
+ props.toggleFunction('item', input)} + collectionFunction={(input) => props.toggleFunction('collection', input)} + filter={''} + moreByCreator={false} + showCreateYourOwn={false} + /> +
+
+ )} + + ); +}; export { ItemPage as default, ItemPage };