feat: implement deep linking support for marketplace items and tabs

This commit is contained in:
alexsparkes
2025-10-28 18:55:13 +00:00
parent ae3c135660
commit e1ad5d490b
7 changed files with 320 additions and 27 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ node_modules/
build/
.idea/
dist/
/docs
# Files
package-lock.json

View File

@@ -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>
);

View File

@@ -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}
/>
);
}

View File

@@ -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>
)}
</>
);

View File

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

View File

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