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