diff --git a/bun.lock b/bun.lock index fb9ab1c1..e30c5e54 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "mue", @@ -23,6 +22,7 @@ "react-dom": "^19.2.3", "react-icons": "^5.5.0", "react-modal": "3.16.3", + "react-router": "^7.13.0", "react-toastify": "11.0.5", "use-debounce": "^10.1.0", }, @@ -488,6 +488,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], "cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.2.0", "", { "dependencies": { "jiti": "^2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ=="], @@ -950,6 +952,8 @@ "react-modal": ["react-modal@3.16.3", "", { "dependencies": { "exenv": "^1.2.0", "prop-types": "^15.7.2", "react-lifecycles-compat": "^3.0.0", "warning": "^4.0.3" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19", "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" } }, "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw=="], + "react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="], + "react-toastify": ["react-toastify@11.0.5", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], @@ -984,6 +988,8 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], diff --git a/package.json b/package.json index 24be9c7f..53006210 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-dom": "^19.2.3", "react-icons": "^5.5.0", "react-modal": "3.16.3", + "react-router": "^7.13.0", "react-toastify": "11.0.5", "use-debounce": "^10.1.0" }, diff --git a/src/App.jsx b/src/App.jsx index 83f6b1d2..ae194ba9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Outlet } from 'react-router'; import { ToastContainer } from 'react-toastify'; import Background from 'features/background/Background'; import Widgets from 'features/misc/views/Widgets'; @@ -82,6 +83,7 @@ const App = () => { + ); }; diff --git a/src/components/Elements/MainModal/Main.jsx b/src/components/Elements/MainModal/Main.jsx index c826fbca..98d194d6 100644 --- a/src/components/Elements/MainModal/Main.jsx +++ b/src/components/Elements/MainModal/Main.jsx @@ -1,4 +1,5 @@ -import { Suspense, lazy, useState, memo, useEffect, useRef } from 'react'; +import { Suspense, lazy, useState, memo, useEffect, useRef, useMemo } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router'; import { useT } from 'contexts'; import './scss/index.scss'; @@ -6,6 +7,7 @@ import ModalLoader from './components/ModalLoader'; import ModalTopBar from './components/ModalTopBar'; import { TAB_TYPES } from './constants/tabConfig'; import { updateHash, parseDeepLink } from 'utils/deepLinking'; +import { useRouterBridge } from '../../../router/RouterBridge'; const Settings = lazy(() => import('../../../features/misc/views/Settings')); const Library = lazy(() => import('../../../features/misc/views/Library')); @@ -19,11 +21,24 @@ const TAB_COMPONENTS = { function MainModal({ modalClose, deepLinkData }) { const t = useT(); - const initialTab = deepLinkData?.tab || TAB_TYPES.SETTINGS; - const [currentTab, setCurrentTab] = useState(initialTab); + const location = useLocation(); + const navigate = useNavigate(); + const params = useParams(); + const { deepLinkData: routerDeepLinkData } = useRouterBridge(); + + // Use router-based deepLinkData if available, fallback to prop + // Memoize to prevent infinite loops in useEffect + const effectiveDeepLinkData = useMemo( + () => routerDeepLinkData || deepLinkData, + [location.pathname, deepLinkData] + ); + + // Derive currentTab from router location instead of state + const currentTab = effectiveDeepLinkData?.tab || TAB_TYPES.SETTINGS; + const [currentSection, setCurrentSection] = useState(''); const [currentSectionName, setCurrentSectionName] = useState(''); - const [currentSubSection, setCurrentSubSection] = useState(deepLinkData?.subSection || null); + const [currentSubSection, setCurrentSubSection] = useState(effectiveDeepLinkData?.subSection || null); const [productView, setProductView] = useState(null); const [resetDiscoverToAll, setResetDiscoverToAll] = useState(false); const [navigationTrigger, setNavigationTrigger] = useState(null); @@ -44,33 +59,25 @@ function MainModal({ modalClose, deepLinkData }) { }, [currentTab]); useEffect(() => { - if (deepLinkData) { - if (deepLinkData.tab && deepLinkData.tab !== currentTab) { - setCurrentTab(deepLinkData.tab); - } - - if (deepLinkData.tab === TAB_TYPES.SETTINGS && deepLinkData.section) { + if (effectiveDeepLinkData) { + if (effectiveDeepLinkData.tab === TAB_TYPES.SETTINGS && effectiveDeepLinkData.section) { setNavigationTrigger({ type: 'settings-section', - data: deepLinkData.section, + data: effectiveDeepLinkData.section, timestamp: Date.now(), }); - if (deepLinkData.subSection) { - setCurrentSubSection(deepLinkData.subSection); - updateHash( - `#${deepLinkData.tab}/${deepLinkData.section}/${deepLinkData.subSection}`, - false, - ); + if (effectiveDeepLinkData.subSection) { + setCurrentSubSection(effectiveDeepLinkData.subSection); if (historyIndexRef.current >= 0) { historyRef.current[historyIndexRef.current] = { ...historyRef.current[historyIndexRef.current], - subSection: deepLinkData.subSection, + subSection: effectiveDeepLinkData.subSection, }; } } } } - }, [deepLinkData]); + }, [effectiveDeepLinkData]); useEffect(() => { return () => { @@ -80,84 +87,73 @@ function MainModal({ modalClose, deepLinkData }) { }; }, []); + // React to router location changes useEffect(() => { - const handlePopState = () => { - const linkData = window.location.hash ? parseDeepLink(window.location.hash) : null; + if (effectiveDeepLinkData) { + if (effectiveDeepLinkData.tab === TAB_TYPES.SETTINGS && effectiveDeepLinkData.section) { + setNavigationTrigger({ + type: 'settings-section', + data: effectiveDeepLinkData.section, + timestamp: Date.now(), + }); + setCurrentSubSection(effectiveDeepLinkData.subSection || null); + return; + } - if (linkData) { - if (linkData.tab && linkData.tab !== currentTab) { - setCurrentTab(linkData.tab); - } - - if (linkData.tab === TAB_TYPES.SETTINGS && linkData.section) { - setNavigationTrigger({ - type: 'settings-section', - data: linkData.section, - timestamp: Date.now(), - }); - setCurrentSubSection(linkData.subSection || null); - return; - } - - if (linkData.itemId && linkData.collection && linkData.fromCollection) { - setNavigationTrigger({ - type: 'collection', - data: linkData.collection, - timestamp: Date.now(), - }); - setTimeout(() => { - setNavigationTrigger({ - type: 'product', - data: { - id: linkData.itemId, - type: linkData.category, - }, - timestamp: Date.now(), - }); - }, 100); - } else if (linkData.itemId) { + if (effectiveDeepLinkData.itemId && effectiveDeepLinkData.collection && effectiveDeepLinkData.fromCollection) { + setNavigationTrigger({ + type: 'collection', + data: effectiveDeepLinkData.collection, + timestamp: Date.now(), + }); + setTimeout(() => { setNavigationTrigger({ type: 'product', data: { - id: linkData.itemId, - type: linkData.category, + id: effectiveDeepLinkData.itemId, + type: effectiveDeepLinkData.category, }, timestamp: Date.now(), }); - } else if (linkData.collection) { - setNavigationTrigger({ - type: 'collection', - data: linkData.collection, - timestamp: Date.now(), - }); - } else { - setProductView(null); - setNavigationTrigger({ - type: 'main', - data: { clearCollection: true }, - timestamp: Date.now(), - }); - } + }, 100); + } else if (effectiveDeepLinkData.itemId) { + setNavigationTrigger({ + type: 'product', + data: { + id: effectiveDeepLinkData.itemId, + type: effectiveDeepLinkData.category, + }, + timestamp: Date.now(), + }); + } else if (effectiveDeepLinkData.collection) { + setNavigationTrigger({ + type: 'collection', + data: effectiveDeepLinkData.collection, + timestamp: Date.now(), + }); + } else { + setProductView(null); + setNavigationTrigger({ + type: 'main', + data: { clearCollection: true }, + timestamp: Date.now(), + }); } - }; - - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, [currentTab]); + } + }, [effectiveDeepLinkData, currentTab]); const handleChangeTab = (newTab) => { - setCurrentTab(newTab); historyRef.current = []; historyIndexRef.current = -1; updateNavButtons(); if (newTab === TAB_TYPES.DISCOVER) { - const section = deepLinkData?.category || deepLinkData?.section || 'all'; - const itemId = deepLinkData?.itemId ? `/${deepLinkData.itemId}` : ''; - updateHash(`#${newTab}/${section}${itemId}`); + const section = effectiveDeepLinkData?.category || effectiveDeepLinkData?.section || 'all'; + const itemId = effectiveDeepLinkData?.itemId ? `/${effectiveDeepLinkData.itemId}` : ''; + navigate(`/${newTab}/${section}${itemId}`); } else if (newTab === TAB_TYPES.LIBRARY) { - updateHash(`#${newTab}/added`); + navigate(`/${newTab}/added`); } else { - updateHash(`#${newTab}`); + navigate(`/${newTab}`); } }; @@ -186,14 +182,14 @@ function MainModal({ modalClose, deepLinkData }) { }; const sectionKey = sectionMap[section]; if (sectionKey) { - updateHash(`#${currentTab}/${sectionKey}`); + navigate(`/${currentTab}/${sectionKey}`); } } else if (currentTab === TAB_TYPES.SETTINGS && sectionName) { // Include subsection in hash if it exists and we're not changing sections - const hash = currentSubSection && (currentSectionName === '' || currentSectionName === sectionName) - ? `#${currentTab}/${sectionName}/${currentSubSection}` - : `#${currentTab}/${sectionName}`; - updateHash(hash, false); + const path = currentSubSection && (currentSectionName === '' || currentSectionName === sectionName) + ? `/${currentTab}/${sectionName}/${currentSubSection}` + : `/${currentTab}/${sectionName}`; + navigate(path, { replace: true }); } }; @@ -213,9 +209,9 @@ function MainModal({ modalClose, deepLinkData }) { } if (currentTab === TAB_TYPES.SETTINGS && sectionName) { if (subSection) { - updateHash(`#${currentTab}/${sectionName}/${subSection}`); + navigate(`/${currentTab}/${sectionName}/${subSection}`); } else { - updateHash(`#${currentTab}/${sectionName}`); + navigate(`/${currentTab}/${sectionName}`); } } }; diff --git a/src/components/Elements/MainModal/components/ModalTopBar.jsx b/src/components/Elements/MainModal/components/ModalTopBar.jsx index a2bfb1dc..21b35125 100644 --- a/src/components/Elements/MainModal/components/ModalTopBar.jsx +++ b/src/components/Elements/MainModal/components/ModalTopBar.jsx @@ -1,9 +1,9 @@ import { useT } from 'contexts/TranslationContext'; import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router'; import { MdClose, MdChevronRight, MdArrowBack, MdArrowForward } from 'react-icons/md'; import { Tooltip, Button } from 'components/Elements'; import { NAVBAR_BUTTONS, TAB_TYPES } from '../constants/tabConfig'; -import { updateHash } from 'utils/deepLinking'; import mueAboutIcon from 'assets/icons/mue_about.png'; const MARKETPLACE_TYPE_TO_KEY = { @@ -45,6 +45,7 @@ function ModalTopBar({ canGoForward, }) { const t = useT(); + const navigate = useNavigate(); const [installedCount, setInstalledCount] = useState(() => { try { @@ -101,7 +102,7 @@ function ModalTopBar({ onClick: (iframeBreadcrumbs && iframeBreadcrumbs.length > 0) || productView ? () => { - updateHash('#discover/all'); + navigate('/discover/all'); } : null, }); @@ -129,19 +130,19 @@ function ModalTopBar({ const urlParams = new URLSearchParams(href.split('?')[1]); const typeParam = urlParams.get('type'); if (typeParam) { - updateHash(`#discover/${typeParam}`); + navigate(`/discover/${typeParam}`); } } else if (href.includes('/collections')) { - updateHash('#discover/collections'); + navigate('/discover/collections'); } else if (href.includes('/collection/')) { const collectionId = href.split('/collection/')[1]?.split('?')[0]; if (collectionId) { - updateHash(`#discover/collection/${collectionId}`); + navigate(`/discover/collection/${collectionId}`); } } else if (categoryKey) { - updateHash(`#discover/${categoryKey}`); + navigate(`/discover/${categoryKey}`); } else if (href === '/marketplace' || href === '/marketplace/') { - updateHash('#discover/all'); + navigate('/discover/all'); } else { const stepsBack = relevantCrumbs.length - index - 1; for (let i = 0; i < stepsBack; i++) { diff --git a/src/features/background/components/BackgroundImage.jsx b/src/features/background/components/BackgroundImage.jsx index 9484c4e4..c7a6d766 100644 --- a/src/features/background/components/BackgroundImage.jsx +++ b/src/features/background/components/BackgroundImage.jsx @@ -1,8 +1,7 @@ import { memo, useState, useEffect } from 'react'; +import { useNavigate } from 'react-router'; import { useT } from 'contexts'; import PhotoInformation from './PhotoInformation'; -import { updateHash } from 'utils/deepLinking'; -import EventBus from 'utils/eventbus'; import { getAllBackgrounds } from 'utils/customBackgroundDB'; /** @@ -10,6 +9,7 @@ import { getAllBackgrounds } from 'utils/customBackgroundDB'; */ function BackgroundImage({ photoInfo, currentAPI, url }) { const t = useT(); + const navigate = useNavigate(); const isCustomType = localStorage.getItem('backgroundType') === 'custom'; const [customBackgrounds, setCustomBackgrounds] = useState([]); const [loading, setLoading] = useState(true); @@ -35,8 +35,7 @@ function BackgroundImage({ photoInfo, currentAPI, url }) { isCustomType && !loading && (!customBackgrounds || customBackgrounds.length === 0); const handleOpenSettings = () => { - updateHash('#settings/background/source'); - EventBus.emit('modal', 'openMainModal'); + navigate('/settings/background/source'); }; return ( diff --git a/src/features/background/options/BackgroundOptions.jsx b/src/features/background/options/BackgroundOptions.jsx index 281af5a9..469dba87 100644 --- a/src/features/background/options/BackgroundOptions.jsx +++ b/src/features/background/options/BackgroundOptions.jsx @@ -1,12 +1,12 @@ import variables from 'config/variables'; import { useT } from 'contexts'; import { memo, useState, useEffect, useCallback, useRef } from 'react'; +import { useNavigate } from 'react-router'; import { MdSource, MdOutlineAutoAwesome } from 'react-icons/md'; import { toast } from 'react-toastify'; import EventBus from 'utils/eventbus'; import { clearQueuesOnSettingChange } from 'utils/queueOperations'; import { uninstall } from 'utils/marketplace'; -import { updateHash } from 'utils/deepLinking'; import { Header } from 'components/Layout/Settings'; import { Dropdown } from 'components/Form/Settings'; @@ -23,6 +23,7 @@ import { getBackgroundOptionItems } from './optionTypes'; const BackgroundOptions = memo(({ currentSubSection, onSubSectionChange, sectionName }) => { const t = useT(); + const navigate = useNavigate(); const [backgroundType, setBackgroundType] = useState( localStorage.getItem('backgroundType') || 'api', ); @@ -143,17 +144,12 @@ const BackgroundOptions = memo(({ currentSubSection, onSubSectionChange, section }; const goToPhotoPacks = () => { - updateHash('#discover/photo_packs'); - const event = new window.Event('popstate'); - window.dispatchEvent(event); + navigate('/discover/photo_packs'); }; const handleToggle = (pack) => { const itemId = pack.name; - updateHash(`#discover/all?item=${itemId}`); - - const event = new window.Event('popstate'); - window.dispatchEvent(event); + navigate(`/discover/item/${itemId}`); variables.stats.postEvent('marketplace', 'ItemPage viewed'); }; diff --git a/src/features/marketplace/components/SuggestedPacks/SuggestedPacks.jsx b/src/features/marketplace/components/SuggestedPacks/SuggestedPacks.jsx index 58d4eee9..ee418b29 100644 --- a/src/features/marketplace/components/SuggestedPacks/SuggestedPacks.jsx +++ b/src/features/marketplace/components/SuggestedPacks/SuggestedPacks.jsx @@ -1,10 +1,10 @@ import React from 'react'; +import { useNavigate } from 'react-router'; import { MdExplore } from 'react-icons/md'; import { useT } from 'contexts'; import { Row, Content, Action } from 'components/Layout/Settings'; import { Button } from 'components/Elements'; import Items from 'features/marketplace/components/Items/Items'; -import { updateHash } from 'utils/deepLinking'; import { useSuggestedPacks } from './useSuggestedPacks'; /** @@ -16,6 +16,7 @@ import { useSuggestedPacks } from './useSuggestedPacks'; */ const SuggestedPacks = ({ category, limit = 4, minToShow = 2 }) => { const t = useT(); + const navigate = useNavigate(); const { suggestions, loading, error } = useSuggestedPacks(category, limit, minToShow); // Don't render anything while loading, on error, or if no suggestions @@ -28,17 +29,13 @@ const SuggestedPacks = ({ category, limit = 4, minToShow = 2 }) => { // Navigate to marketplace category page const goToMarketplace = () => { - updateHash(`#discover/${category}`); - const event = new window.Event('popstate'); - window.dispatchEvent(event); + navigate(`/discover/${category}`); }; // Navigate to specific item detail page const navigateToItem = (item) => { const itemId = item.id || item.name; - updateHash(`#discover/${category}/${itemId}`); - const event = new window.Event('popstate'); - window.dispatchEvent(event); + navigate(`/discover/${category}/${itemId}`); }; return ( diff --git a/src/features/marketplace/views/Added.jsx b/src/features/marketplace/views/Added.jsx index 5b65e70f..24dcc7fa 100644 --- a/src/features/marketplace/views/Added.jsx +++ b/src/features/marketplace/views/Added.jsx @@ -1,6 +1,7 @@ import { useT } from 'contexts'; import variables from 'config/variables'; import { memo, useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router'; import { MdUpdate, MdOutlineExtensionOff, @@ -19,10 +20,10 @@ import { Header, CustomActions } from 'components/Layout/Settings'; import { Button } from 'components/Elements'; import { install, uninstall } from 'utils/marketplace'; -import { updateHash } from 'utils/deepLinking'; const Added = memo(() => { const t = useT(); + const navigate = useNavigate(); const [installed, setInstalled] = useState(JSON.parse(localStorage.getItem('installed'))); const [showFailed, setShowFailed] = useState(false); const [failedReason, setFailedReason] = useState(''); @@ -82,14 +83,11 @@ const Added = memo(() => { const toggle = useCallback((type, data) => { if (type === 'item') { const itemId = data.name; - updateHash(`#discover/all?item=${itemId}`); - - const event = new window.Event('popstate'); - window.dispatchEvent(event); + navigate(`/discover/item/${itemId}`); variables.stats.postEvent('marketplace', 'ItemPage viewed'); } - }, []); + }, [navigate]); const sortAddons = useCallback((value, sendEvent) => { const installedItems = JSON.parse(localStorage.getItem('installed')); @@ -197,10 +195,8 @@ const Added = memo(() => { ); const goToDiscover = useCallback(() => { - updateHash('#discover/all'); - const event = new window.Event('popstate'); - window.dispatchEvent(event); - }, []); + navigate('/discover/all'); + }, [navigate]); const toggleViewType = useCallback((type) => { setViewType(type); diff --git a/src/features/misc/modals/Modals.jsx b/src/features/misc/modals/Modals.jsx index e06b2bfc..c41dd7b9 100644 --- a/src/features/misc/modals/Modals.jsx +++ b/src/features/misc/modals/Modals.jsx @@ -1,5 +1,6 @@ import variables from 'config/variables'; import { useState, useEffect, lazy, Suspense } from 'react'; +import { useLocation, useNavigate } from 'react-router'; import Modal from 'react-modal'; import { MainModal } from 'components/Elements'; @@ -9,6 +10,7 @@ import Preview from '../../helpers/preview/Preview'; import EventBus from 'utils/eventbus'; import { parseDeepLink, shouldAutoOpenModal, updateHash } from 'utils/deepLinking'; import { install } from 'utils/marketplace'; +import { useRouterBridge } from '../../../router/RouterBridge'; const Welcome = lazy(() => import('features/welcome/Welcome')); @@ -48,30 +50,38 @@ const tryInstallDefaultPack = async () => { }; const Modals = () => { + const location = useLocation(); + const navigate = useNavigate(); + const { deepLinkData } = useRouterBridge(); + const [mainModal, setMainModal] = useState(false); const [updateModal, setUpdateModal] = useState(false); const [welcomeModal, setWelcomeModal] = useState(false); const [appsModal, setAppsModal] = useState(false); const [preview, setPreview] = useState(false); - const [deepLinkData, setDeepLinkData] = useState(null); + + // Sync modal open state with router location + useEffect(() => { + const hasRoute = location.pathname !== '/'; + if (hasRoute && !mainModal) { + setMainModal(true); + if (deepLinkData?.tab) { + variables.stats.postEvent('modal', `Opened via deep link: ${deepLinkData.tab}`); + } + } else if (!hasRoute && mainModal) { + setMainModal(false); + } + }, [location.pathname, mainModal, deepLinkData]); useEffect(() => { const isPreviewMode = localStorage.getItem('showWelcome') === 'true'; if (isPreviewMode && shouldAutoOpenModal()) { - window.history.replaceState(null, null, '/'); + navigate('/'); setWelcomeModal(true); setPreview(false); return; } - if (shouldAutoOpenModal()) { - const linkData = parseDeepLink(); - setMainModal(true); - setDeepLinkData(linkData); - variables.stats.postEvent('modal', `Opened via deep link: ${linkData.tab}`); - return; - } - if ( localStorage.getItem('showWelcome') === 'true' && window.location.search !== '?nointro=true' @@ -102,25 +112,17 @@ const Modals = () => { const handleModalOpen = (data) => { if (data === 'openMainModal') { - const linkData = parseDeepLink(); - setDeepLinkData(linkData); + navigate('/settings'); setMainModal(true); } }; - const handleHashChange = () => { - const linkData = parseDeepLink(); - setDeepLinkData(linkData); - }; - EventBus.on('modal', handleModalOpen); - window.addEventListener('popstate', handleHashChange); return () => { EventBus.off('modal', handleModalOpen); - window.removeEventListener('popstate', handleHashChange); }; - }, []); + }, [navigate]); const closeWelcome = async () => { localStorage.setItem('showWelcome', false); @@ -156,8 +158,11 @@ const Modals = () => { if (action !== false) { variables.stats.postEvent('modal', `Opened ${type.replace('Modal', '')}`); if (type === 'mainModal') { - updateHash('#settings'); + navigate('/settings'); } + } else if (action === false && type === 'mainModal') { + // When closing modal, navigate to root + navigate('/'); } }; diff --git a/src/features/misc/views/Discover.jsx b/src/features/misc/views/Discover.jsx index a6f7d2f3..069db67c 100644 --- a/src/features/misc/views/Discover.jsx +++ b/src/features/misc/views/Discover.jsx @@ -2,7 +2,7 @@ import { useT } from 'contexts'; import variables from 'config/variables'; import { MARKETPLACE_URL } from 'config/constants'; import { memo, useEffect, useRef, useState } from 'react'; -import { updateHash } from 'utils/deepLinking'; +import { useNavigate } from 'react-router'; import { MdOutlineWifiOff } from 'react-icons/md'; import Modal from 'react-modal'; import Tabs from 'components/Elements/MainModal/backend/Tabs'; @@ -11,6 +11,7 @@ import Lightbox from 'features/marketplace/components/Elements/Lightbox/Lightbox function DiscoverContent({ category, onBreadcrumbsChange, deepLinkData }) { const t = useT(); + const navigate = useNavigate(); const iframeRef = useRef(null); const [loading, setLoading] = useState(true); const [showLightbox, setShowLightbox] = useState(false); @@ -62,49 +63,6 @@ function DiscoverContent({ category, onBreadcrumbsChange, deepLinkData }) { } }, [category, onBreadcrumbsChange, previewParam, deepLinkData]); - useEffect(() => { - const checkAndLoadItem = () => { - const hash = window.location.hash; - const urlParams = new URLSearchParams(hash.split('?')[1]); - const itemId = urlParams.get('item'); - - if (itemId && iframeRef.current) { - setLoading(true); - - const theme = getResolvedTheme(); - const themeParam = `&theme=${theme}`; - - const installed = JSON.parse(localStorage.getItem('installed')) || []; - const item = installed.find((i) => i.name === itemId); - - if (item) { - const pathMap = { - photo_packs: 'packs', - quote_packs: 'packs', - preset_settings: 'presets', - }; - - const pathSegment = pathMap[item.type] || 'packs'; - const itemIdToUse = item.id || itemId; - - iframeRef.current.src = `${MARKETPLACE_URL}/${pathSegment}/${itemIdToUse}?embed=true${previewParam}${themeParam}`; - } else { - iframeRef.current.src = `${MARKETPLACE_URL}/packs/${itemId}?embed=true${previewParam}${themeParam}`; - } - } - }; - - checkAndLoadItem(); - - window.addEventListener('hashchange', checkAndLoadItem); - window.addEventListener('popstate', checkAndLoadItem); - - return () => { - window.removeEventListener('hashchange', checkAndLoadItem); - window.removeEventListener('popstate', checkAndLoadItem); - }; - }, [category, previewParam]); - useEffect(() => { if (deepLinkData?.itemId && iframeRef.current) { setLoading(true); @@ -117,9 +75,26 @@ function DiscoverContent({ category, onBreadcrumbsChange, deepLinkData }) { preset_settings: 'presets', }; - const pathSegment = deepLinkData.category - ? pathMap[deepLinkData.category] || 'packs' - : 'packs'; + let pathSegment = 'packs'; // default + + // If category is known and not 'all', use it + if (deepLinkData.category && deepLinkData.category !== 'all') { + pathSegment = pathMap[deepLinkData.category] || 'packs'; + } else { + // Look up the item to determine its type + const installed = JSON.parse(localStorage.getItem('installed')) || []; + const item = installed.find((i) => i.name === deepLinkData.itemId || i.id === deepLinkData.itemId); + + if (item) { + const typeMap = { + 'photos': 'packs', + 'quotes': 'packs', + 'settings': 'presets', + }; + pathSegment = typeMap[item.type] || 'packs'; + } + } + iframeRef.current.src = `${MARKETPLACE_URL}/${pathSegment}/${deepLinkData.itemId}?embed=true${previewParam}${themeParam}`; } }, [deepLinkData, previewParam]); @@ -215,9 +190,10 @@ function DiscoverContent({ category, onBreadcrumbsChange, deepLinkData }) { case 'marketplace:navigate': if (payload?.itemId) { - updateHash(`#discover/${payload.itemId}`); + // Use the simpler item route + navigate(`/discover/item/${payload.itemId}`); } else if (payload?.category) { - updateHash(`#discover/${payload.category}`); + navigate(`/discover/${payload.category}`); } break; @@ -231,7 +207,7 @@ function DiscoverContent({ category, onBreadcrumbsChange, deepLinkData }) { return () => { window.removeEventListener('message', handleMessage); }; - }, [installItem, uninstallItem, onBreadcrumbsChange]); + }, [installItem, uninstallItem, onBreadcrumbsChange, navigate]); const handleLoad = () => { setLoading(false); diff --git a/src/features/quote/options/QuoteOptions.jsx b/src/features/quote/options/QuoteOptions.jsx index e139da95..3e185347 100644 --- a/src/features/quote/options/QuoteOptions.jsx +++ b/src/features/quote/options/QuoteOptions.jsx @@ -1,6 +1,7 @@ import { useT } from 'contexts'; import variables from 'config/variables'; import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router'; import EventBus from 'utils/eventbus'; import { MdCancel, @@ -28,11 +29,11 @@ import { FREQUENCY_OPTIONS } from 'utils/frequencyManager'; import Items from 'features/marketplace/components/Items/Items'; import SuggestedPacks from 'features/marketplace/components/SuggestedPacks'; import { uninstall } from 'utils/marketplace'; -import { updateHash } from 'utils/deepLinking'; import './QuoteOptions.scss'; const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) => { const t = useT(); + const navigate = useNavigate(); const getCustom = () => { let data = JSON.parse(localStorage.getItem('customQuote')); if (data === null) { @@ -333,17 +334,12 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) => }; const goToQuotePacks = () => { - updateHash('#discover/quote_packs'); - const event = new window.Event('popstate'); - window.dispatchEvent(event); + navigate('/discover/quote_packs'); }; const handleToggle = (pack) => { const itemId = pack.name; - updateHash(`#discover/all?item=${itemId}`); - - const event = new window.Event('popstate'); - window.dispatchEvent(event); + navigate(`/discover/item/${itemId}`); variables.stats.postEvent('marketplace', 'ItemPage viewed'); }; diff --git a/src/index.jsx b/src/index.jsx index b6c7e3de..7efa923c 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,4 +1,5 @@ import { createRoot } from 'react-dom/client'; +import { RouterProvider } from 'react-router'; import variables from 'config/variables'; import * as Sentry from '@sentry/react'; @@ -6,6 +7,7 @@ import * as Sentry from '@sentry/react'; import App from './App'; import ErrorBoundary from './ErrorBoundary'; import { migrateAPIUsersToPhotoPacks } from './utils/migrations'; +import { router } from './router'; import './scss/index.scss'; import 'react-toastify/dist/ReactToastify.css'; @@ -38,7 +40,7 @@ const root = createRoot(container); root.render( - + , ); } catch (error) { diff --git a/src/router/ModalRouter.jsx b/src/router/ModalRouter.jsx new file mode 100644 index 00000000..deb18a24 --- /dev/null +++ b/src/router/ModalRouter.jsx @@ -0,0 +1,23 @@ +import { Suspense } from 'react'; +import { RouterProvider } from 'react-router'; +import { router } from './index'; + +/** + * ModalRouter - Wraps modal content with React Router + * + * This component integrates React Router v7 with the existing modal system. + * It provides hash-based routing within the modal while maintaining + * compatibility with the existing modal open/close logic. + * + * During the transition: + * - The modal open/close state remains managed by Modals.jsx + * - The router controls what content is shown when modal is open + * - Deep linking continues to work via hash URLs + */ +export function ModalRouter({ children, fallback }) { + return ( + }> + + + ); +} diff --git a/src/router/RouterBridge.jsx b/src/router/RouterBridge.jsx new file mode 100644 index 00000000..bc04d23b --- /dev/null +++ b/src/router/RouterBridge.jsx @@ -0,0 +1,87 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router'; +import { parseDeepLink } from 'utils/deepLinking'; + +/** + * RouterBridge - Bridges React Router with existing navigation system + * + * During migration, this component helps coordinate between: + * 1. React Router's location state + * 2. Existing deepLinkData prop system + * 3. Legacy hash-based navigation + * + * This allows gradual migration without breaking existing functionality + */ +export function useRouterBridge() { + const location = useLocation(); + const navigate = useNavigate(); + const params = useParams(); + + // Convert router location to deepLinkData format for backward compatibility + const getDeepLinkData = () => { + const pathParts = location.pathname.split('/').filter(Boolean); + + if (pathParts.length === 0) { + return null; + } + + const tab = pathParts[0]; // settings, discover, or library + const section = pathParts[1]; // section name or category + const subSection = pathParts[2]; // subsection or item ID + const itemId = pathParts[2]; // for discover items + + const result = { + tab, + section: section || null, + subSection: subSection || null, + itemId: itemId || null, + }; + + // Special handling for discover routes + if (tab === 'discover') { + result.category = section || null; + + // If we have a collection route + if (section === 'collection') { + result.collection = subSection; + result.fromCollection = !!pathParts[3]; + if (pathParts[3]) { + result.itemId = pathParts[3]; + } + } + // If we have an item route without category (/discover/item/:itemId) + else if (section === 'item') { + result.itemId = subSection; + result.category = 'all'; // Default to 'all' category + } + } + + return result; + }; + + // Note: Hash syncing is handled by React Router's createHashRouter + // No manual sync needed - would cause infinite loops + + return { + deepLinkData: getDeepLinkData(), + navigate, + location, + params, + }; +} + +/** + * Hook to navigate using the router in place of updateHash + * Provides API compatibility with legacy navigation system + */ +export function useRouterNavigation() { + const navigate = useNavigate(); + + const navigateToPath = (hash) => { + // Remove leading '#' if present + const path = hash.startsWith('#') ? hash.slice(1) : hash; + navigate(path); + }; + + return { navigate: navigateToPath }; +} diff --git a/src/router/index.jsx b/src/router/index.jsx new file mode 100644 index 00000000..cb47d53b --- /dev/null +++ b/src/router/index.jsx @@ -0,0 +1,9 @@ +import { createHashRouter } from 'react-router'; +import { routes } from './routes'; + +/** + * Create hash-based router for browser extension compatibility + * Hash router ensures all routes stay within the extension page + * and prevents Chrome from looking for non-existent physical files + */ +export const router = createHashRouter(routes); diff --git a/src/router/routes.jsx b/src/router/routes.jsx new file mode 100644 index 00000000..883e7bfd --- /dev/null +++ b/src/router/routes.jsx @@ -0,0 +1,87 @@ +import { lazy } from 'react'; +import { Navigate } from 'react-router'; +import App from '../App'; + +// Lazy load tab components +const Settings = lazy(() => import('../features/misc/views/Settings')); +const Discover = lazy(() => import('../features/misc/views/Discover')); +const Library = lazy(() => import('../features/misc/views/Library')); + +/** + * React Router v7 route configuration for Mue browser extension + * Uses hash-based routing for browser extension compatibility + * + * Note: During migration, routes are used for state management. + * The App component renders the full page with background/widgets. + * The Modals component within App handles the actual modal rendering. + */ +export const routes = [ + { + path: '/', + element: , + children: [ + { + index: true, + // Empty index route - app without modal open + element:
, + }, + { + path: 'settings', + element:
, + }, + { + path: 'settings/:section', + element:
, + }, + { + path: 'settings/:section/:subsection', + element:
, + }, + { + path: 'discover', + element:
, + }, + { + path: 'discover/:category', + element:
, + }, + { + path: 'discover/:category/:itemId', + element:
, + }, + { + path: 'discover/item/:itemId', + element:
, + }, + { + path: 'discover/collection/:collectionId', + element:
, + }, + { + path: 'discover/collection/:collectionId/:itemId', + element:
, + }, + { + path: 'library', + element:
, + }, + { + path: 'library/:section', + element:
, + }, + // Legacy marketplace alias redirects to discover + { + path: 'marketplace', + element: , + }, + { + path: 'marketplace/:category', + element: , + }, + { + path: 'marketplace/:category/:itemId', + element: , + }, + ], + }, +]; diff --git a/src/utils/deepLinking.js b/src/utils/deepLinking.js index 7b16c649..ad4157ce 100644 --- a/src/utils/deepLinking.js +++ b/src/utils/deepLinking.js @@ -111,6 +111,10 @@ 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) + * + * NOTE: During React Router migration, we don't dispatch popstate events + * because React Router's HashRouter already handles hash changes automatically. + * Dispatching extra events causes infinite navigation loops. */ export const updateHash = (hash, pushToHistory = true) => { if (window.history.pushState) { @@ -119,7 +123,9 @@ export const updateHash = (hash, pushToHistory = true) => { } else { window.history.replaceState(null, null, hash); } - window.dispatchEvent(new PopStateEvent('popstate')); + // Commented out to prevent infinite loops with React Router + // React Router's HashRouter automatically detects hash changes + // window.dispatchEvent(new PopStateEvent('popstate')); } else { window.location.hash = hash; }