mirror of
https://github.com/mue/mue.git
synced 2026-07-13 12:07:45 +02:00
feat(modal): new navigation with back and forth arrow
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Suspense, lazy, useState, memo, useEffect } from 'react';
|
||||
import variables from 'config/variables';
|
||||
|
||||
import './scss/index.scss';
|
||||
import ModalLoader from './components/ModalLoader';
|
||||
@@ -21,6 +22,18 @@ 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);
|
||||
const [currentSection, setCurrentSection] = useState('');
|
||||
const [productView, setProductView] = useState(null);
|
||||
const [resetDiscoverToAll, setResetDiscoverToAll] = useState(false);
|
||||
const [navigationHistory, setNavigationHistory] = useState([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [navigationTrigger, setNavigationTrigger] = useState(null);
|
||||
const [skipNextHistoryAdd, setSkipNextHistoryAdd] = useState(0);
|
||||
|
||||
// Clear product view when changing tabs
|
||||
useEffect(() => {
|
||||
setProductView(null);
|
||||
}, [currentTab]);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for hash changes while modal is open
|
||||
@@ -33,22 +46,232 @@ function MainModal({ modalClose, deepLinkData }) {
|
||||
return cleanup;
|
||||
}, [currentTab]);
|
||||
|
||||
const addToHistory = (state) => {
|
||||
// Check if this state is different from the current one
|
||||
const currentState = navigationHistory[historyIndex];
|
||||
const isDifferent = !currentState ||
|
||||
currentState.tab !== state.tab ||
|
||||
currentState.section !== state.section ||
|
||||
JSON.stringify(currentState.product) !== JSON.stringify(state.product);
|
||||
|
||||
if (isDifferent) {
|
||||
const newHistory = navigationHistory.slice(0, historyIndex + 1);
|
||||
newHistory.push(state);
|
||||
setNavigationHistory(newHistory);
|
||||
setHistoryIndex(newHistory.length - 1);
|
||||
console.log('Added to history:', state, 'New history:', newHistory);
|
||||
} else {
|
||||
console.log('Skipping duplicate history entry:', state);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeTab = (newTab) => {
|
||||
// Only add to history if not navigating via history
|
||||
if (skipNextHistoryAdd === 0) {
|
||||
addToHistory({
|
||||
tab: newTab,
|
||||
section: '',
|
||||
product: null,
|
||||
});
|
||||
} else {
|
||||
setSkipNextHistoryAdd(0); // Reset skip counter
|
||||
}
|
||||
|
||||
setCurrentTab(newTab);
|
||||
// Update URL hash when tab changes
|
||||
updateHash(`#${newTab}`);
|
||||
if (newTab === TAB_TYPES.DISCOVER) {
|
||||
updateHash(`#${newTab}/all`);
|
||||
} else if (newTab === TAB_TYPES.LIBRARY) {
|
||||
updateHash(`#${newTab}/added`);
|
||||
} else {
|
||||
updateHash(`#${newTab}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSectionChange = (section) => {
|
||||
setCurrentSection(section);
|
||||
// Update URL hash when section changes
|
||||
if (currentTab === TAB_TYPES.DISCOVER) {
|
||||
// For Discover tab, update with the section type
|
||||
const sectionMap = {
|
||||
[variables.getMessage('modals.main.marketplace.all')]: 'all',
|
||||
[variables.getMessage('modals.main.marketplace.photo_packs')]: 'photo_packs',
|
||||
[variables.getMessage('modals.main.marketplace.quote_packs')]: 'quote_packs',
|
||||
[variables.getMessage('modals.main.marketplace.preset_settings')]: 'preset_settings',
|
||||
[variables.getMessage('modals.main.marketplace.collections')]: 'collections',
|
||||
};
|
||||
const sectionKey = sectionMap[section];
|
||||
if (sectionKey) {
|
||||
updateHash(`#${currentTab}/${sectionKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't add section changes to history - they're automatic
|
||||
// Only track user-initiated navigation (tab switches and product views)
|
||||
};
|
||||
|
||||
const handleProductView = (product) => {
|
||||
setProductView(product);
|
||||
|
||||
// Add to navigation history only if not skipping
|
||||
// Store only essential product info, not full object
|
||||
if (product && skipNextHistoryAdd === 0) {
|
||||
addToHistory({
|
||||
tab: currentTab,
|
||||
section: currentSection,
|
||||
product: {
|
||||
type: product.type,
|
||||
name: product.name,
|
||||
id: product.id,
|
||||
},
|
||||
});
|
||||
} else if (skipNextHistoryAdd > 0) {
|
||||
console.log('Skipping product view history add');
|
||||
setSkipNextHistoryAdd(0); // Reset after use
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetDiscoverToAll = () => {
|
||||
setResetDiscoverToAll(true);
|
||||
setTimeout(() => setResetDiscoverToAll(false), 100);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
const previousState = navigationHistory[newIndex];
|
||||
console.log('Going back to:', previousState);
|
||||
|
||||
setHistoryIndex(newIndex);
|
||||
|
||||
// Set skip flag BEFORE changing any state
|
||||
setSkipNextHistoryAdd(1);
|
||||
|
||||
// Change tab if different
|
||||
if (previousState.tab !== currentTab) {
|
||||
setCurrentTab(previousState.tab);
|
||||
}
|
||||
|
||||
setCurrentSection(previousState.section);
|
||||
setProductView(previousState.product);
|
||||
|
||||
// Trigger navigation in child components
|
||||
if (previousState.product) {
|
||||
// Viewing a product
|
||||
setNavigationTrigger({
|
||||
type: 'product',
|
||||
data: previousState.product,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
updateHash(`#${previousState.tab}/${previousState.product.type}/${previousState.product.id}`);
|
||||
} else {
|
||||
// Viewing main view
|
||||
setNavigationTrigger({
|
||||
type: 'main',
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (previousState.tab === TAB_TYPES.DISCOVER) {
|
||||
const sectionMap = {
|
||||
[variables.getMessage('modals.main.marketplace.all')]: 'all',
|
||||
[variables.getMessage('modals.main.marketplace.photo_packs')]: 'photo_packs',
|
||||
[variables.getMessage('modals.main.marketplace.quote_packs')]: 'quote_packs',
|
||||
[variables.getMessage('modals.main.marketplace.preset_settings')]: 'preset_settings',
|
||||
[variables.getMessage('modals.main.marketplace.collections')]: 'collections',
|
||||
};
|
||||
const sectionKey = sectionMap[previousState.section] || 'all';
|
||||
updateHash(`#${previousState.tab}/${sectionKey}`);
|
||||
} else if (previousState.tab === TAB_TYPES.LIBRARY) {
|
||||
updateHash(`#${previousState.tab}/added`);
|
||||
} else {
|
||||
updateHash(`#${previousState.tab}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
if (historyIndex < navigationHistory.length - 1) {
|
||||
const newIndex = historyIndex + 1;
|
||||
const nextState = navigationHistory[newIndex];
|
||||
console.log('Going forward to:', nextState);
|
||||
|
||||
setHistoryIndex(newIndex);
|
||||
|
||||
// Set skip flag BEFORE changing any state
|
||||
setSkipNextHistoryAdd(1);
|
||||
|
||||
// Change tab if different
|
||||
if (nextState.tab !== currentTab) {
|
||||
setCurrentTab(nextState.tab);
|
||||
}
|
||||
|
||||
setCurrentSection(nextState.section);
|
||||
setProductView(nextState.product);
|
||||
|
||||
// Trigger navigation in child components
|
||||
if (nextState.product) {
|
||||
setNavigationTrigger({
|
||||
type: 'product',
|
||||
data: nextState.product,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
updateHash(`#${nextState.tab}/${nextState.product.type}/${nextState.product.id}`);
|
||||
} else {
|
||||
setNavigationTrigger({
|
||||
type: 'main',
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (nextState.tab === TAB_TYPES.DISCOVER) {
|
||||
const sectionMap = {
|
||||
[variables.getMessage('modals.main.marketplace.all')]: 'all',
|
||||
[variables.getMessage('modals.main.marketplace.photo_packs')]: 'photo_packs',
|
||||
[variables.getMessage('modals.main.marketplace.quote_packs')]: 'quote_packs',
|
||||
[variables.getMessage('modals.main.marketplace.preset_settings')]: 'preset_settings',
|
||||
[variables.getMessage('modals.main.marketplace.collections')]: 'collections',
|
||||
};
|
||||
const sectionKey = sectionMap[nextState.section] || 'all';
|
||||
updateHash(`#${nextState.tab}/${sectionKey}`);
|
||||
} else if (nextState.tab === TAB_TYPES.LIBRARY) {
|
||||
updateHash(`#${nextState.tab}/added`);
|
||||
} else {
|
||||
updateHash(`#${nextState.tab}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < navigationHistory.length - 1;
|
||||
|
||||
const TabComponent = TAB_COMPONENTS[currentTab] || Settings;
|
||||
|
||||
return (
|
||||
<div className="frame">
|
||||
<ModalTopBar currentTab={currentTab} onTabChange={handleChangeTab} onClose={modalClose} />
|
||||
<ModalTopBar
|
||||
currentTab={currentTab}
|
||||
currentSection={currentSection}
|
||||
productView={productView}
|
||||
onTabChange={handleChangeTab}
|
||||
onClose={modalClose}
|
||||
onBack={handleBack}
|
||||
onForward={handleForward}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
/>
|
||||
<Suspense fallback={<ModalLoader />}>
|
||||
<TabComponent
|
||||
changeTab={handleChangeTab}
|
||||
deepLinkData={deepLinkData}
|
||||
currentTab={currentTab}
|
||||
onSectionChange={handleSectionChange}
|
||||
onProductView={handleProductView}
|
||||
resetToAll={resetDiscoverToAll}
|
||||
onResetToAll={handleResetDiscoverToAll}
|
||||
navigationTrigger={navigationTrigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import variables from 'config/variables';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Tab from './Tab';
|
||||
import ReminderInfo from '../components/ReminderInfo';
|
||||
import ErrorBoundary from '../../../../features/misc/modals/ErrorBoundary';
|
||||
import { TAB_TYPES } from '../constants/tabConfig';
|
||||
|
||||
const Tabs = ({ children, navbar = false, currentTab: activeTab }) => {
|
||||
const Tabs = ({ children, navbar = false, currentTab: activeTab, onSectionChange, resetToFirst }) => {
|
||||
const [currentTab, setCurrentTab] = useState(children[0]?.props.label);
|
||||
const [currentName, setCurrentName] = useState(children[0]?.props.name);
|
||||
const [showReminder, setShowReminder] = useState(localStorage.getItem('showReminder') === 'true');
|
||||
@@ -17,8 +17,31 @@ const Tabs = ({ children, navbar = false, currentTab: activeTab }) => {
|
||||
|
||||
setCurrentTab(tab);
|
||||
setCurrentName(name);
|
||||
|
||||
// Notify parent of section change
|
||||
if (onSectionChange) {
|
||||
onSectionChange(tab);
|
||||
}
|
||||
};
|
||||
|
||||
// Notify parent of initial section on mount
|
||||
useEffect(() => {
|
||||
if (onSectionChange && currentTab) {
|
||||
onSectionChange(currentTab);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reset to first tab when requested
|
||||
useEffect(() => {
|
||||
if (resetToFirst) {
|
||||
setCurrentTab(children[0]?.props.label);
|
||||
setCurrentName(children[0]?.props.name);
|
||||
if (onSectionChange) {
|
||||
onSectionChange(children[0]?.props.label);
|
||||
}
|
||||
}
|
||||
}, [resetToFirst]);
|
||||
|
||||
const handleHideReminder = () => {
|
||||
localStorage.setItem('showReminder', 'false');
|
||||
setShowReminder(false);
|
||||
|
||||
@@ -1,18 +1,121 @@
|
||||
import variables from 'config/variables';
|
||||
import { MdClose } from 'react-icons/md';
|
||||
import { MdClose, MdChevronRight, MdArrowBack, MdArrowForward } from 'react-icons/md';
|
||||
import { Tooltip, Button } from 'components/Elements';
|
||||
import { NAVBAR_BUTTONS } from '../constants/tabConfig';
|
||||
|
||||
function ModalTopBar({ currentTab, onTabChange, onClose }) {
|
||||
// Map marketplace types to translation keys
|
||||
const MARKETPLACE_TYPE_TO_KEY = {
|
||||
photo_packs: 'modals.main.marketplace.photo_packs',
|
||||
photos: 'modals.main.marketplace.photo_packs',
|
||||
quote_packs: 'modals.main.marketplace.quote_packs',
|
||||
quotes: 'modals.main.marketplace.quote_packs',
|
||||
preset_settings: 'modals.main.marketplace.preset_settings',
|
||||
settings: 'modals.main.marketplace.preset_settings',
|
||||
collections: 'modals.main.marketplace.collections',
|
||||
all: 'modals.main.marketplace.all',
|
||||
};
|
||||
|
||||
function ModalTopBar({
|
||||
currentTab,
|
||||
currentSection,
|
||||
productView,
|
||||
onTabChange,
|
||||
onClose,
|
||||
onBack,
|
||||
onForward,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
}) {
|
||||
// Get the current tab label
|
||||
const currentTabButton = NAVBAR_BUTTONS.find(({ tab }) => tab === currentTab);
|
||||
const currentTabLabel = currentTabButton
|
||||
? variables.getMessage(currentTabButton.messageKey)
|
||||
: '';
|
||||
|
||||
// Determine breadcrumb path with click handlers
|
||||
const breadcrumbPath = [];
|
||||
|
||||
if (currentTabLabel) {
|
||||
breadcrumbPath.push({
|
||||
label: currentTabLabel,
|
||||
onClick: productView ? productView.onBackToAll : null, // Clickable if viewing a product
|
||||
});
|
||||
|
||||
if (productView) {
|
||||
// Show: Discover > Category > Product
|
||||
const categoryKey = MARKETPLACE_TYPE_TO_KEY[productView.type];
|
||||
if (categoryKey) {
|
||||
breadcrumbPath.push({
|
||||
label: variables.getMessage(categoryKey),
|
||||
onClick: productView.onBack || null,
|
||||
});
|
||||
}
|
||||
breadcrumbPath.push({
|
||||
label: productView.name,
|
||||
onClick: null, // Current item - not clickable
|
||||
});
|
||||
} else if (currentSection) {
|
||||
// Show: Tab > Section
|
||||
breadcrumbPath.push({
|
||||
label: currentSection,
|
||||
onClick: null, // Current section - not clickable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modalTopBar">
|
||||
<div className="topBarLeft">
|
||||
<div className="navigationButtons">
|
||||
<Tooltip title="Back" key="backTooltip">
|
||||
<button
|
||||
className="navButton"
|
||||
onClick={onBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Navigate back"
|
||||
>
|
||||
<MdArrowBack />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Forward" key="forwardTooltip">
|
||||
<button
|
||||
className="navButton"
|
||||
onClick={onForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Navigate forward"
|
||||
>
|
||||
<MdArrowForward />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<img
|
||||
src="src/assets/icons/mue_about.png"
|
||||
alt="Mue"
|
||||
className="topBarLogo"
|
||||
draggable={false}
|
||||
/>
|
||||
{breadcrumbPath.length > 0 && (
|
||||
<div className="breadcrumbs">
|
||||
{breadcrumbPath.map((item, index) => {
|
||||
const isLast = index === breadcrumbPath.length - 1;
|
||||
const isClickable = item.onClick !== null;
|
||||
|
||||
return (
|
||||
<span key={index} className="breadcrumb-segment">
|
||||
<span
|
||||
className={`breadcrumb-item ${isLast ? 'breadcrumb-current' : ''} ${
|
||||
isClickable ? 'breadcrumb-clickable' : ''
|
||||
}`}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{!isLast && <MdChevronRight className="breadcrumb-separator" />}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topBarRight">
|
||||
<div className="topBarNavigation">
|
||||
|
||||
@@ -11,11 +11,94 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
|
||||
.navigationButtons {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
|
||||
.navButton {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
transition: 0.3s;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.topBarLogo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
|
||||
.breadcrumb-segment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
opacity: 0.5;
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.topBarRight {
|
||||
|
||||
Reference in New Issue
Block a user