diff --git a/src/components/Elements/MainModal/Main.jsx b/src/components/Elements/MainModal/Main.jsx index 92d1f712..6463601a 100644 --- a/src/components/Elements/MainModal/Main.jsx +++ b/src/components/Elements/MainModal/Main.jsx @@ -5,7 +5,7 @@ import './scss/index.scss'; import ModalLoader from './components/ModalLoader'; import ModalTopBar from './components/ModalTopBar'; import { TAB_TYPES } from './constants/tabConfig'; -import { updateHash, onHashChange } from 'utils/deepLinking'; +import { updateHash, parseDeepLink } from 'utils/deepLinking'; const Settings = lazy(() => import('../../../features/misc/views/Settings')); const Library = lazy(() => import('../../../features/misc/views/Library')); @@ -25,58 +25,88 @@ function MainModal({ modalClose, deepLinkData }) { const [currentSection, setCurrentSection] = useState(''); const [productView, setProductView] = useState(null); const [resetDiscoverToAll, setResetDiscoverToAll] = useState(false); - const [navigationHistory, setNavigationHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); const [navigationTrigger, setNavigationTrigger] = useState(null); - const [skipNextHistoryAdd, setSkipNextHistoryAdd] = useState(0); // Clear product view when changing tabs useEffect(() => { setProductView(null); }, [currentTab]); + // Clear hash when modal closes useEffect(() => { - // Listen for hash changes while modal is open - const cleanup = onHashChange((linkData) => { - if (linkData && linkData.tab !== currentTab) { - setCurrentTab(linkData.tab); + return () => { + // When modal unmounts, clear the hash + if (window.location.hash) { + window.history.replaceState(null, null, window.location.pathname); } - }); + }; + }, []); - return cleanup; + useEffect(() => { + // Listen for browser back/forward navigation via popstate + const handlePopState = () => { + const linkData = window.location.hash ? parseDeepLink(window.location.hash) : null; + + if (linkData) { + // Update tab if different + if (linkData.tab && linkData.tab !== currentTab) { + setCurrentTab(linkData.tab); + } + + // Handle product and collection navigation + if (linkData.itemId && linkData.collection && linkData.fromCollection) { + // Product viewed from within a collection + // First set collection state, then navigate to product + setNavigationTrigger({ + type: 'collection', + data: linkData.collection, + timestamp: Date.now(), + }); + // Small delay to ensure collection state is set before navigating to product + setTimeout(() => { + setNavigationTrigger({ + type: 'product', + data: { + id: linkData.itemId, + type: linkData.category, + }, + timestamp: Date.now(), + }); + }, 100); + } else if (linkData.itemId) { + // Product navigation (standalone) + setNavigationTrigger({ + type: 'product', + data: { + id: linkData.itemId, + type: linkData.category, + }, + timestamp: Date.now(), + }); + } else if (linkData.collection) { + // Collection page navigation + setNavigationTrigger({ + type: 'collection', + data: linkData.collection, + timestamp: Date.now(), + }); + } else { + // Back to main view (clear collection state) + setProductView(null); + setNavigationTrigger({ + type: 'main', + data: { clearCollection: true }, + timestamp: Date.now(), + }); + } + } + }; + + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); }, [currentTab]); - const addToHistory = (state) => { - // Check if this state is different from the current one - const currentState = navigationHistory[historyIndex]; - const isDifferent = !currentState || - currentState.tab !== state.tab || - currentState.section !== state.section || - JSON.stringify(currentState.product) !== JSON.stringify(state.product); - - if (isDifferent) { - const newHistory = navigationHistory.slice(0, historyIndex + 1); - newHistory.push(state); - setNavigationHistory(newHistory); - setHistoryIndex(newHistory.length - 1); - console.log('Added to history:', state, 'New history:', newHistory); - } else { - console.log('Skipping duplicate history entry:', state); - } - }; - const handleChangeTab = (newTab) => { - // Only add to history if not navigating via history - if (skipNextHistoryAdd === 0) { - addToHistory({ - tab: newTab, - section: '', - product: null, - }); - } else { - setSkipNextHistoryAdd(0); // Reset skip counter - } - setCurrentTab(newTab); // Update URL hash when tab changes if (newTab === TAB_TYPES.DISCOVER) { @@ -112,23 +142,8 @@ function MainModal({ modalClose, deepLinkData }) { const handleProductView = (product) => { setProductView(product); - - // Add to navigation history only if not skipping - // Store only essential product info, not full object - if (product && skipNextHistoryAdd === 0) { - addToHistory({ - tab: currentTab, - section: currentSection, - product: { - type: product.type, - name: product.name, - id: product.id, - }, - }); - } else if (skipNextHistoryAdd > 0) { - console.log('Skipping product view history add'); - setSkipNextHistoryAdd(0); // Reset after use - } + // URL hash is already updated by child components (Browse.jsx) + // Browser history automatically tracks hash changes }; const handleResetDiscoverToAll = () => { @@ -137,115 +152,17 @@ function MainModal({ modalClose, deepLinkData }) { }; const handleBack = () => { - if (historyIndex > 0) { - const newIndex = historyIndex - 1; - const previousState = navigationHistory[newIndex]; - console.log('Going back to:', previousState); - - setHistoryIndex(newIndex); - - // Set skip flag BEFORE changing any state - setSkipNextHistoryAdd(1); - - // Change tab if different - if (previousState.tab !== currentTab) { - setCurrentTab(previousState.tab); - } - - setCurrentSection(previousState.section); - setProductView(previousState.product); - - // Trigger navigation in child components - if (previousState.product) { - // Viewing a product - setNavigationTrigger({ - type: 'product', - data: previousState.product, - timestamp: Date.now(), - }); - updateHash(`#${previousState.tab}/${previousState.product.type}/${previousState.product.id}`); - } else { - // Viewing main view - setNavigationTrigger({ - type: 'main', - data: null, - timestamp: Date.now(), - }); - - if (previousState.tab === TAB_TYPES.DISCOVER) { - const sectionMap = { - [variables.getMessage('modals.main.marketplace.all')]: 'all', - [variables.getMessage('modals.main.marketplace.photo_packs')]: 'photo_packs', - [variables.getMessage('modals.main.marketplace.quote_packs')]: 'quote_packs', - [variables.getMessage('modals.main.marketplace.preset_settings')]: 'preset_settings', - [variables.getMessage('modals.main.marketplace.collections')]: 'collections', - }; - const sectionKey = sectionMap[previousState.section] || 'all'; - updateHash(`#${previousState.tab}/${sectionKey}`); - } else if (previousState.tab === TAB_TYPES.LIBRARY) { - updateHash(`#${previousState.tab}/added`); - } else { - updateHash(`#${previousState.tab}`); - } - } - } + window.history.back(); }; const handleForward = () => { - if (historyIndex < navigationHistory.length - 1) { - const newIndex = historyIndex + 1; - const nextState = navigationHistory[newIndex]; - console.log('Going forward to:', nextState); - - setHistoryIndex(newIndex); - - // Set skip flag BEFORE changing any state - setSkipNextHistoryAdd(1); - - // Change tab if different - if (nextState.tab !== currentTab) { - setCurrentTab(nextState.tab); - } - - setCurrentSection(nextState.section); - setProductView(nextState.product); - - // Trigger navigation in child components - if (nextState.product) { - setNavigationTrigger({ - type: 'product', - data: nextState.product, - timestamp: Date.now(), - }); - updateHash(`#${nextState.tab}/${nextState.product.type}/${nextState.product.id}`); - } else { - setNavigationTrigger({ - type: 'main', - data: null, - timestamp: Date.now(), - }); - - if (nextState.tab === TAB_TYPES.DISCOVER) { - const sectionMap = { - [variables.getMessage('modals.main.marketplace.all')]: 'all', - [variables.getMessage('modals.main.marketplace.photo_packs')]: 'photo_packs', - [variables.getMessage('modals.main.marketplace.quote_packs')]: 'quote_packs', - [variables.getMessage('modals.main.marketplace.preset_settings')]: 'preset_settings', - [variables.getMessage('modals.main.marketplace.collections')]: 'collections', - }; - const sectionKey = sectionMap[nextState.section] || 'all'; - updateHash(`#${nextState.tab}/${sectionKey}`); - } else if (nextState.tab === TAB_TYPES.LIBRARY) { - updateHash(`#${nextState.tab}/added`); - } else { - updateHash(`#${nextState.tab}`); - } - } - } + window.history.forward(); }; - const canGoBack = historyIndex > 0; - const canGoForward = historyIndex < navigationHistory.length - 1; + // Browser manages history state, so we always show buttons enabled + // Browser will handle whether there's actually history to go back/forward + const canGoBack = true; + const canGoForward = true; const TabComponent = TAB_COMPONENTS[currentTab] || Settings; @@ -264,6 +181,7 @@ function MainModal({ modalClose, deepLinkData }) { /> }> - {showSidebar && ( + {showSidebar ? (
{children.map((tab, index) => (
- )} -
+ ) : null} +
{children.map((tab, index) => { if (tab.props.label !== currentTab) { return null; diff --git a/src/components/Elements/MainModal/components/ModalTopBar.jsx b/src/components/Elements/MainModal/components/ModalTopBar.jsx index 1928300a..df2495e9 100644 --- a/src/components/Elements/MainModal/components/ModalTopBar.jsx +++ b/src/components/Elements/MainModal/components/ModalTopBar.jsx @@ -43,18 +43,43 @@ function ModalTopBar({ }); if (productView) { - // Show: Discover > Category > Product - const categoryKey = MARKETPLACE_TYPE_TO_KEY[productView.type]; - if (categoryKey) { + console.log('ModalTopBar productView:', productView); + console.log('fromCollection:', productView.fromCollection, 'isCollection:', productView.isCollection, 'collectionTitle:', productView.collectionTitle); + + // If viewing a collection page itself (not a product within it) + if (productView.isCollection) { + // Show: Discover > Collection Name breadcrumbPath.push({ - label: variables.getMessage(categoryKey), - onClick: productView.onBack || null, + label: productView.collectionTitle || productView.name, + onClick: null, // Current page - not clickable + }); + } else { + // Viewing a product + // Show: Discover > Collection/Category > Product + if (productView.fromCollection && productView.collectionTitle) { + console.log('Showing collection breadcrumb:', productView.collectionTitle); + // If from a collection, show collection name + breadcrumbPath.push({ + label: productView.collectionTitle, + onClick: productView.onBack || null, + }); + } else { + console.log('Showing category breadcrumb'); + // Otherwise show category + const categoryKey = MARKETPLACE_TYPE_TO_KEY[productView.type]; + if (categoryKey) { + breadcrumbPath.push({ + label: variables.getMessage(categoryKey), + onClick: productView.onBack || null, + }); + } + } + // Add product name as final breadcrumb + breadcrumbPath.push({ + label: productView.name, + onClick: null, // Current item - not clickable }); } - breadcrumbPath.push({ - label: productView.name, - onClick: null, // Current item - not clickable - }); } else if (currentSection) { // Show: Tab > Section breadcrumbPath.push({ diff --git a/src/features/marketplace/views/Added.jsx b/src/features/marketplace/views/Added.jsx index f2ec749c..5404f96f 100644 --- a/src/features/marketplace/views/Added.jsx +++ b/src/features/marketplace/views/Added.jsx @@ -1,6 +1,6 @@ import variables from 'config/variables'; import { memo, useState, useEffect, useCallback } from 'react'; -import { MdUpdate, MdOutlineExtensionOff, MdSendTimeExtension } from 'react-icons/md'; +import { MdUpdate, MdOutlineExtensionOff, MdSendTimeExtension, MdExplore } from 'react-icons/md'; import { toast } from 'react-toastify'; import Modal from 'react-modal'; @@ -12,6 +12,7 @@ import { Header, CustomActions } from 'components/Layout/Settings'; import { Button } from 'components/Elements'; import { install, uninstall, urlParser } from 'utils/marketplace'; +import { updateHash } from 'utils/deepLinking'; const Added = memo(() => { const [installed, setInstalled] = useState(JSON.parse(localStorage.getItem('installed'))); @@ -203,6 +204,13 @@ const Added = memo(() => { ); + const goToDiscover = useCallback(() => { + updateHash('#discover/all'); + // Trigger a popstate event to update the UI + const event = new window.Event('popstate'); + window.dispatchEvent(event); + }, []); + if (installed.length === 0) { return ( <> @@ -219,6 +227,12 @@ const Added = memo(() => { {variables.getMessage('modals.main.addons.empty.description')} +
diff --git a/src/features/marketplace/views/Browse.jsx b/src/features/marketplace/views/Browse.jsx index 4c859fbc..0b2abfea 100644 --- a/src/features/marketplace/views/Browse.jsx +++ b/src/features/marketplace/views/Browse.jsx @@ -12,7 +12,6 @@ import { import ItemPage from './ItemPage'; import Items from '../components/Items/Items'; -import { Header } from 'components/Layout/Settings'; import { Button } from 'components/Elements'; import { install, urlParser, uninstall } from 'utils/marketplace'; @@ -99,20 +98,28 @@ class Marketplace extends PureComponent { // Error caught but only used for flow control if (this.controller.signal.aborted === false) { console.error('Failed to fetch item:', error); - return toast(variables.getMessage('toasts.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')); + const installed = JSON.parse(localStorage.getItem('installed')) || []; if (installed.some((item) => item.name === info.data.name)) { button = this.buttons.uninstall; @@ -146,24 +153,49 @@ class Marketplace extends PureComponent { // Update URL hash with item ID for deep linking if (info.data?.id) { - updateHash(`#discover/${info.data.type}/${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) { - 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: () => { - this.toggle('main'); - updateHash('#discover/all'); - if (this.props.onResetToAll) { - this.props.onResetToAll(); - } + // 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; @@ -177,21 +209,82 @@ class Marketplace extends PureComponent { }) ).json(); - this.setState({ + 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); + } } else { this.setState({ item: {}, relatedItems: [] }); - // Clear hash when returning to main view - updateHash(`#discover/${this.props.type}`); + + // 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) { @@ -325,7 +418,15 @@ class Marketplace extends PureComponent { } returnToMain() { - this.setState({ items: this.state.oldItems, collection: false }); + this.setState({ + items: this.state.oldItems, + collection: false, + collectionName: null, + collectionTitle: null, + collectionDescription: null, + collectionImg: null, + }); + updateHash(`#discover/${this.props.type}`); } componentDidMount() { @@ -363,9 +464,28 @@ class Marketplace extends PureComponent { 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) - this.toggle('main'); + // 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'); + } } } } @@ -445,12 +565,6 @@ class Marketplace extends PureComponent { <> {this.state.collection === true ? ( <> -
this.returnToMain()} - />
{ tab: 'marketplace', itemId: 'f41219846700' } - * #marketplace/preset_settings/f41219846700 -> { tab: 'marketplace', category: 'preset_settings', itemId: 'f41219846700' } - * #marketplace/collection/featured -> { tab: 'marketplace', collection: 'featured' } + * #discover/f41219846700 -> { tab: 'discover', itemId: 'f41219846700' } + * #discover/preset_settings/f41219846700 -> { tab: 'discover', category: 'preset_settings', itemId: 'f41219846700' } + * #discover/collection/featured -> { tab: 'discover', collection: 'featured' } + * #discover/collection/featured/f41219846700 -> { tab: 'discover', collection: 'featured', itemId: 'f41219846700' } + * #marketplace/74ef53ceed0b -> { tab: 'discover', itemId: '74ef53ceed0b' } (marketplace is aliased to discover) * #settings/appearance -> { tab: 'settings', section: 'appearance' } * #addons -> { tab: 'addons' } * * Legacy format (still supported): - * #marketplace/quote_packs/digital-stoicism -> converted to item lookup + * #discover/quote_packs/digital-stoicism -> converted to item lookup */ export const parseDeepLink = (hash = window.location.hash) => { if (!hash || hash === '#') { @@ -34,25 +36,36 @@ export const parseDeepLink = (hash = window.location.hash) => { itemId: parts[2], }; + // Handle marketplace as an alias for discover (for backward compatibility with external URLs) + if (result.tab === 'marketplace') { + result.tab = 'discover'; + } + // Validate tab const validTabs = Object.values(TAB_TYPES); if (!validTabs.includes(result.tab)) { return null; } - // Handle marketplace-specific parsing - if (result.tab === 'marketplace') { + // Handle discover-specific parsing (marketplace URLs are aliased to discover) + if (result.tab === 'discover') { // Check if it's a collection if (result.section === 'collection') { result.collection = result.itemId; - result.itemId = null; + // Check if there's a 4th part (item ID within collection) + if (parts[3]) { + result.itemId = parts[3]; + result.fromCollection = true; + } else { + result.itemId = null; + } } // Check if section is a category (preset_settings, photo_packs, quote_packs) else if (['preset_settings', 'photo_packs', 'quote_packs', 'all'].includes(result.section)) { result.category = result.section; // Third part is the item ID (already in result.itemId) } - // If only one part after marketplace, assume it's an item ID + // If only one part after tab, assume it's an item ID else if (result.section && !result.itemId) { result.itemId = result.section; result.section = null; @@ -64,20 +77,25 @@ export const parseDeepLink = (hash = window.location.hash) => { /** * Create a deep link hash - * @param {string} tab - The main tab (settings, marketplace, addons) + * @param {string} tab - The main tab (settings, discover, addons) * @param {object} options - Additional options - * @param {string} options.itemId - Item ID for marketplace items (v2 format) - * @param {string} options.category - Category for marketplace items (optional) - * @param {string} options.collection - Collection name for marketplace + * @param {string} options.itemId - Item ID for discover/marketplace items (v2 format) + * @param {string} options.category - Category for discover/marketplace items (optional) + * @param {string} options.collection - Collection name for discover/marketplace + * @param {boolean} options.fromCollection - If item is being viewed from within a collection * @param {string} options.section - Section within the tab * @returns {string} Hash string */ export const createDeepLink = (tab, options = {}) => { let hash = `#${tab}`; - if (tab === 'marketplace') { - // Collection link - if (options.collection) { + if (tab === 'discover') { + // Collection with item (item viewed from within a collection) + if (options.collection && options.itemId && options.fromCollection) { + hash += `/collection/${options.collection}/${options.itemId}`; + } + // Collection link (just the collection page) + else if (options.collection) { hash += `/collection/${options.collection}`; } // Item with category @@ -101,10 +119,16 @@ export const createDeepLink = (tab, options = {}) => { /** * Update URL hash without triggering page reload + * @param {string} hash - The hash to set + * @param {boolean} pushToHistory - If true, adds to browser history (default: true) */ -export const updateHash = (hash) => { +export const updateHash = (hash, pushToHistory = true) => { if (window.history.pushState) { - window.history.pushState(null, null, hash); + if (pushToHistory) { + window.history.pushState(null, null, hash); + } else { + window.history.replaceState(null, null, hash); + } } else { window.location.hash = hash; }