mirror of
https://github.com/mue/mue.git
synced 2026-06-05 23:45:53 +02:00
feat: implement deep linking support for marketplace items and tabs
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules/
|
||||
build/
|
||||
.idea/
|
||||
dist/
|
||||
/docs
|
||||
|
||||
# Files
|
||||
package-lock.json
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import variables from 'config/variables';
|
||||
import { Suspense, lazy, useState, memo } from 'react';
|
||||
import { Suspense, lazy, useState, memo, useEffect } from 'react';
|
||||
import { MdClose } from 'react-icons/md';
|
||||
|
||||
import './scss/index.scss';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import ModalLoader from './components/ModalLoader';
|
||||
import { TAB_TYPES } from './constants/tabConfig';
|
||||
import { updateHash, onHashChange } from 'utils/deepLinking';
|
||||
|
||||
const Settings = lazy(() => import('../../../features/misc/views/Settings'));
|
||||
const Addons = lazy(() => import('../../../features/misc/views/Addons'));
|
||||
@@ -18,8 +19,27 @@ const TAB_COMPONENTS = {
|
||||
[TAB_TYPES.MARKETPLACE]: Marketplace,
|
||||
};
|
||||
|
||||
function MainModal({ modalClose }) {
|
||||
const [currentTab, setCurrentTab] = useState(TAB_TYPES.SETTINGS);
|
||||
function MainModal({ modalClose, deepLinkData }) {
|
||||
// Initialize with deep link tab if provided, otherwise default to settings
|
||||
const initialTab = deepLinkData?.tab || TAB_TYPES.SETTINGS;
|
||||
const [currentTab, setCurrentTab] = useState(initialTab);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for hash changes while modal is open
|
||||
const cleanup = onHashChange((linkData) => {
|
||||
if (linkData && linkData.tab !== currentTab) {
|
||||
setCurrentTab(linkData.tab);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [currentTab]);
|
||||
|
||||
const handleChangeTab = (newTab) => {
|
||||
setCurrentTab(newTab);
|
||||
// Update URL hash when tab changes
|
||||
updateHash(`#${newTab}`);
|
||||
};
|
||||
|
||||
const TabComponent = TAB_COMPONENTS[currentTab] || Settings;
|
||||
|
||||
@@ -35,7 +55,7 @@ function MainModal({ modalClose }) {
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Suspense fallback={<ModalLoader />}>
|
||||
<TabComponent changeTab={setCurrentTab} />
|
||||
<TabComponent changeTab={handleChangeTab} deepLinkData={deepLinkData} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,11 +17,23 @@ import { Header } from 'components/Layout/Settings';
|
||||
import { Button } from 'components/Elements';
|
||||
|
||||
import { install, urlParser, uninstall } from 'utils/marketplace';
|
||||
import { updateHash } from 'utils/deepLinking';
|
||||
|
||||
// API v2 base URL
|
||||
const API_V2_BASE = `${variables.constants.API_URL}/marketplace`;
|
||||
|
||||
class Marketplace extends PureComponent {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = { items: [], button: '', done: false, item: {}, collection: false, filter: '' };
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
items: [],
|
||||
relatedItems: [],
|
||||
button: '',
|
||||
done: false,
|
||||
item: {},
|
||||
collection: false,
|
||||
filter: '',
|
||||
};
|
||||
this.buttons = {
|
||||
uninstall: (
|
||||
<Button
|
||||
@@ -46,19 +58,48 @@ class Marketplace extends PureComponent {
|
||||
async toggle(pageType, data) {
|
||||
if (pageType === 'item') {
|
||||
let info;
|
||||
// get item info
|
||||
let relatedItems = [];
|
||||
|
||||
// get item info using API v2
|
||||
try {
|
||||
let type = this.props.type;
|
||||
if (type === 'all' || type === 'collections') {
|
||||
type = data.type;
|
||||
}
|
||||
|
||||
// API v2: Fetch by ID if available, otherwise by name
|
||||
const itemEndpoint = data.id
|
||||
? `${API_V2_BASE}/item/${data.id}`
|
||||
: `${API_V2_BASE}/item/${type}/${data.name}`;
|
||||
|
||||
info = await (
|
||||
await fetch(`${variables.constants.API_URL}/marketplace/item/${type}/${data.name}`, {
|
||||
await fetch(itemEndpoint, {
|
||||
signal: this.controller.signal,
|
||||
})
|
||||
).json();
|
||||
} catch (e) {
|
||||
|
||||
// Fetch related items using API v2
|
||||
if (info.data?.id) {
|
||||
try {
|
||||
const relatedResponse = await fetch(`${API_V2_BASE}/item/${info.data.id}/related`, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
const relatedData = await relatedResponse.json();
|
||||
relatedItems = relatedData.data?.related || [];
|
||||
} catch (relatedError) {
|
||||
console.warn('Failed to fetch related items:', relatedError);
|
||||
}
|
||||
|
||||
// Track view using API v2
|
||||
fetch(`${API_V2_BASE}/item/${info.data.id}/view`, {
|
||||
method: 'POST',
|
||||
signal: this.controller.signal,
|
||||
}).catch(() => {}); // Silent fail for analytics
|
||||
}
|
||||
} catch (error) {
|
||||
// Error caught but only used for flow control
|
||||
if (this.controller.signal.aborted === false) {
|
||||
console.error('Failed to fetch item:', error);
|
||||
return toast(variables.getMessage('toasts.error'));
|
||||
}
|
||||
}
|
||||
@@ -87,12 +128,12 @@ class Marketplace extends PureComponent {
|
||||
|
||||
this.setState({
|
||||
item: {
|
||||
id: info.data.id, // Store item ID for deep linking
|
||||
onCollection: data._onCollection,
|
||||
type: info.data.type,
|
||||
display_name: info.data.name,
|
||||
display_name: info.data.name || info.data.display_name,
|
||||
author: info.data.author,
|
||||
description: urlParser(info.data.description.replace(/\n/g, '<br>')),
|
||||
//updated: info.updated,
|
||||
version: info.data.version,
|
||||
icon: info.data.screenshot_url,
|
||||
data: info.data,
|
||||
@@ -100,17 +141,26 @@ class Marketplace extends PureComponent {
|
||||
addonInstalledVersion,
|
||||
api_name: data.name,
|
||||
},
|
||||
relatedItems,
|
||||
button: button,
|
||||
});
|
||||
|
||||
// Update URL hash with item ID for deep linking
|
||||
if (info.data?.id) {
|
||||
updateHash(`#marketplace/${info.data.type}/${info.data.id}`);
|
||||
}
|
||||
|
||||
document.querySelector('#modal').scrollTop = 0;
|
||||
variables.stats.postEvent('marketplace-item', `${this.state.item.display_name} viewed`);
|
||||
} else if (pageType === 'collection') {
|
||||
this.setState({ done: false, item: {} });
|
||||
// Use API v2 for collections
|
||||
const collection = await (
|
||||
await fetch(`${variables.constants.API_URL}/marketplace/collection/${data}`, {
|
||||
await fetch(`${API_V2_BASE}/collection/${data}`, {
|
||||
signal: this.controller.signal,
|
||||
})
|
||||
).json();
|
||||
|
||||
this.setState({
|
||||
items: collection.data.items,
|
||||
collectionTitle: collection.data.display_name,
|
||||
@@ -119,20 +169,28 @@ class Marketplace extends PureComponent {
|
||||
collection: true,
|
||||
done: true,
|
||||
});
|
||||
|
||||
// Update hash for collection deep linking
|
||||
updateHash(`#marketplace/collection/${data}`);
|
||||
} else {
|
||||
this.setState({ item: {} });
|
||||
this.setState({ item: {}, relatedItems: [] });
|
||||
// Clear hash when returning to main view
|
||||
updateHash('#marketplace');
|
||||
}
|
||||
}
|
||||
|
||||
async getItems() {
|
||||
this.setState({ done: false });
|
||||
|
||||
// Use API v2 endpoints
|
||||
const dataURL =
|
||||
this.props.type === 'collections'
|
||||
? variables.constants.API_URL + '/marketplace/collections'
|
||||
: variables.constants.API_URL + '/marketplace/items/' + this.props.type;
|
||||
? `${API_V2_BASE}/collections`
|
||||
: `${API_V2_BASE}/items/${this.props.type}`;
|
||||
|
||||
const { data } = await (await fetch(dataURL, { signal: this.controller.signal })).json();
|
||||
const collections = await (
|
||||
await fetch(variables.constants.API_URL + '/marketplace/collections', {
|
||||
await fetch(`${API_V2_BASE}/collections`, {
|
||||
signal: this.controller.signal,
|
||||
})
|
||||
).json();
|
||||
@@ -177,11 +235,18 @@ class Marketplace extends PureComponent {
|
||||
const installed = JSON.parse(localStorage.getItem('installed'));
|
||||
for (const item of this.state.items) {
|
||||
if (installed.some((i) => i.name === item.display_name)) continue; // don't install if already installed
|
||||
|
||||
// Use API v2 - fetch by ID if available, otherwise by name
|
||||
const itemEndpoint = item.id
|
||||
? `${API_V2_BASE}/item/${item.id}`
|
||||
: `${API_V2_BASE}/item/${item.type}/${item.name}`;
|
||||
|
||||
const { data } = await (
|
||||
await fetch(`${variables.constants.API_URL}/marketplace/item/${item.type}/${item.name}`, {
|
||||
await fetch(itemEndpoint, {
|
||||
signal: this.controller.signal,
|
||||
})
|
||||
).json();
|
||||
|
||||
install(data.type, data, false, true);
|
||||
variables.stats.postEvent('marketplace-item', `${item.display_name} installed}`);
|
||||
variables.stats.postEvent('marketplace', 'Install');
|
||||
@@ -248,6 +313,22 @@ class Marketplace extends PureComponent {
|
||||
}
|
||||
|
||||
this.getItems();
|
||||
|
||||
// Handle deep link data if provided
|
||||
if (this.props.deepLinkData) {
|
||||
const { itemId, collection, category } = this.props.deepLinkData;
|
||||
|
||||
// Wait for items to load, then open the specific item or collection
|
||||
setTimeout(() => {
|
||||
if (collection) {
|
||||
// Open collection
|
||||
this.toggle('collection', collection);
|
||||
} else if (itemId) {
|
||||
// Open specific item by ID
|
||||
this.toggle('item', { id: itemId, type: category });
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -316,6 +397,7 @@ class Marketplace extends PureComponent {
|
||||
addonInstalled={this.state.item.addonInstalled}
|
||||
addonInstalledVersion={this.state.item.addonInstalledVersion}
|
||||
icon={this.state.item.screenshot_url}
|
||||
relatedItems={this.state.relatedItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ class ItemPage extends PureComponent {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{moreByCurator.length > 1 && (
|
||||
{/* {moreByCurator.length > 1 && (
|
||||
<div className="moreFromCurator">
|
||||
<span className="title">
|
||||
{variables.getMessage('modals.main.marketplace.product.more_from_curator', {
|
||||
@@ -426,6 +426,26 @@ class ItemPage extends PureComponent {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
{this.props.relatedItems && this.props.relatedItems.length > 0 && (
|
||||
<div className="moreFromCurator">
|
||||
<span className="title">
|
||||
{variables.getMessage('modals.main.marketplace.you_might_also_like') ||
|
||||
'You might also like'}
|
||||
</span>
|
||||
<div>
|
||||
<Items
|
||||
type={this.props.data.data.type}
|
||||
items={this.props.relatedItems}
|
||||
onCollection={false}
|
||||
toggleFunction={(input) => this.props.toggleFunction('item', input)}
|
||||
collectionFunction={(input) => this.props.toggleFunction('collection', input)}
|
||||
filter={''}
|
||||
moreByCreator={false}
|
||||
showCreateYourOwn={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import Navbar from '../../navbar/Navbar';
|
||||
import Preview from '../../helpers/preview/Preview';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
import { parseDeepLink, shouldAutoOpenModal } from 'utils/deepLinking';
|
||||
|
||||
import Welcome from 'features/welcome/Welcome';
|
||||
|
||||
@@ -19,10 +20,22 @@ export default class Modals extends PureComponent {
|
||||
welcomeModal: false,
|
||||
appsModal: false,
|
||||
preview: false,
|
||||
deepLinkData: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Check for deep link first (has priority)
|
||||
if (shouldAutoOpenModal()) {
|
||||
const deepLinkData = parseDeepLink();
|
||||
this.setState({
|
||||
mainModal: true,
|
||||
deepLinkData,
|
||||
});
|
||||
variables.stats.postEvent('modal', `Opened via deep link: ${deepLinkData.tab}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
localStorage.getItem('showWelcome') === 'true' &&
|
||||
window.location.search !== '?nointro=true'
|
||||
@@ -90,7 +103,10 @@ export default class Modals extends PureComponent {
|
||||
overlayClassName="Overlay"
|
||||
ariaHideApp={false}
|
||||
>
|
||||
<MainModal modalClose={() => this.toggleModal('mainModal', false)} />
|
||||
<MainModal
|
||||
modalClose={() => this.toggleModal('mainModal', false)}
|
||||
deepLinkData={this.state.deepLinkData}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
closeTimeoutMS={300}
|
||||
|
||||
@@ -4,26 +4,26 @@ import { memo } from 'react';
|
||||
import Tabs from '../../../components/Elements/MainModal/backend/Tabs';
|
||||
import MarketplaceTab from '../../marketplace/views/Browse';
|
||||
|
||||
function Marketplace(props) {
|
||||
function Marketplace({ changeTab, deepLinkData }) {
|
||||
return (
|
||||
<Tabs changeTab={(type) => props.changeTab(type)} current="marketplace">
|
||||
<Tabs changeTab={(type) => changeTab(type)} current="marketplace">
|
||||
<div label={variables.getMessage('modals.main.marketplace.all')} name="all">
|
||||
<MarketplaceTab type="all" />
|
||||
<MarketplaceTab type="all" deepLinkData={deepLinkData} />
|
||||
</div>
|
||||
<div label={variables.getMessage('modals.main.marketplace.photo_packs')} name="photo_packs">
|
||||
<MarketplaceTab type="photo_packs" />
|
||||
<MarketplaceTab type="photo_packs" deepLinkData={deepLinkData} />
|
||||
</div>
|
||||
<div label={variables.getMessage('modals.main.marketplace.quote_packs')} name="quote_packs">
|
||||
<MarketplaceTab type="quote_packs" />
|
||||
<MarketplaceTab type="quote_packs" deepLinkData={deepLinkData} />
|
||||
</div>
|
||||
<div
|
||||
label={variables.getMessage('modals.main.marketplace.preset_settings')}
|
||||
name="preset_settings"
|
||||
>
|
||||
<MarketplaceTab type="preset_settings" />
|
||||
<MarketplaceTab type="preset_settings" deepLinkData={deepLinkData} />
|
||||
</div>
|
||||
<div label={variables.getMessage('modals.main.marketplace.collections')} name="collections">
|
||||
<MarketplaceTab type="collections" />
|
||||
<MarketplaceTab type="collections" deepLinkData={deepLinkData} />
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
154
src/utils/deepLinking.js
Normal file
154
src/utils/deepLinking.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Deep linking utilities for opening specific marketplace items or tabs
|
||||
* from external sources (e.g., website)
|
||||
*
|
||||
* Updated for Marketplace API v2 with item IDs
|
||||
*/
|
||||
|
||||
import { TAB_TYPES } from '../components/Elements/MainModal/constants/tabConfig';
|
||||
|
||||
/**
|
||||
* Parse hash from URL
|
||||
* Examples (NEW API v2 format):
|
||||
* #marketplace/f41219846700 -> { tab: 'marketplace', itemId: 'f41219846700' }
|
||||
* #marketplace/preset_settings/f41219846700 -> { tab: 'marketplace', category: 'preset_settings', itemId: 'f41219846700' }
|
||||
* #marketplace/collection/featured -> { tab: 'marketplace', collection: 'featured' }
|
||||
* #settings/appearance -> { tab: 'settings', section: 'appearance' }
|
||||
* #addons -> { tab: 'addons' }
|
||||
*
|
||||
* Legacy format (still supported):
|
||||
* #marketplace/quote_packs/digital-stoicism -> converted to item lookup
|
||||
*/
|
||||
export const parseDeepLink = (hash = window.location.hash) => {
|
||||
if (!hash || hash === '#') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove the # symbol
|
||||
const path = hash.slice(1);
|
||||
const parts = path.split('/');
|
||||
|
||||
const result = {
|
||||
tab: parts[0],
|
||||
section: parts[1],
|
||||
itemId: parts[2],
|
||||
};
|
||||
|
||||
// Validate tab
|
||||
const validTabs = Object.values(TAB_TYPES);
|
||||
if (!validTabs.includes(result.tab)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle marketplace-specific parsing
|
||||
if (result.tab === 'marketplace') {
|
||||
// Check if it's a collection
|
||||
if (result.section === 'collection') {
|
||||
result.collection = result.itemId;
|
||||
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
|
||||
else if (result.section && !result.itemId) {
|
||||
result.itemId = result.section;
|
||||
result.section = null;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a deep link hash
|
||||
* @param {string} tab - The main tab (settings, marketplace, 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.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) {
|
||||
hash += `/collection/${options.collection}`;
|
||||
}
|
||||
// Item with category
|
||||
else if (options.itemId && options.category) {
|
||||
hash += `/${options.category}/${options.itemId}`;
|
||||
}
|
||||
// Item without category (direct ID)
|
||||
else if (options.itemId) {
|
||||
hash += `/${options.itemId}`;
|
||||
}
|
||||
// Category only
|
||||
else if (options.category) {
|
||||
hash += `/${options.category}`;
|
||||
}
|
||||
} else if (options.section) {
|
||||
hash += `/${options.section}`;
|
||||
}
|
||||
|
||||
return hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update URL hash without triggering page reload
|
||||
*/
|
||||
export const updateHash = (hash) => {
|
||||
if (window.history.pushState) {
|
||||
window.history.pushState(null, null, hash);
|
||||
} else {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Listen for hash changes
|
||||
*/
|
||||
export const onHashChange = (callback) => {
|
||||
const handler = () => {
|
||||
const deepLink = parseDeepLink();
|
||||
if (deepLink) {
|
||||
callback(deepLink);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handler);
|
||||
|
||||
// Return cleanup function
|
||||
return () => window.removeEventListener('hashchange', handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if extension should open modal on load based on hash
|
||||
*/
|
||||
export const shouldAutoOpenModal = () => {
|
||||
const deepLink = parseDeepLink();
|
||||
return deepLink !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert item name to ID (for backward compatibility)
|
||||
* This requires fetching from API to resolve name -> ID
|
||||
* @param {string} category - Item category
|
||||
* @param {string} name - Item name
|
||||
* @returns {Promise<string|null>} Item ID or null
|
||||
*/
|
||||
export const resolveItemNameToId = async (category, name, apiUrl) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/v2/marketplace/item/${category}/${name}`);
|
||||
const data = await response.json();
|
||||
return data?.data?.id || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve item name to ID:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user