From 780f946effd4ce9099df9cbca51fb57307992658 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 1 Aug 2026 06:45:25 +0200 Subject: [PATCH] Route article detail tabs in the URL and pin a preferred default. Use /artikel/:sku/:tab with a localStorage-backed default, and a pin control to set which tab opens first. --- src/App.jsx | 2 +- src/components/article/ArticleTileTab.jsx | 270 +++++++++++++--------- src/components/article/ArticleView.jsx | 12 +- src/pages/artikel/articleRoute.js | 105 ++++++++- src/pages/artikel/index.jsx | 3 +- src/pages/artikel/sku.jsx | 28 ++- 6 files changed, 302 insertions(+), 118 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index e3dcd07..48d4038 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -81,7 +81,7 @@ class App extends Component { } /> } /> } /> - } /> + } /> }> } /> diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx index 6c3ba00..c5df224 100644 --- a/src/components/article/ArticleTileTab.jsx +++ b/src/components/article/ArticleTileTab.jsx @@ -1,6 +1,7 @@ import { Component } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; import Typography from '@mui/material/Typography'; @@ -11,56 +12,55 @@ import HistoryIcon from '@mui/icons-material/History'; import LabelIcon from '@mui/icons-material/Label'; import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import ManageSearchIcon from '@mui/icons-material/ManageSearch'; +import PushPinIcon from '@mui/icons-material/PushPin'; import SettingsIcon from '@mui/icons-material/Settings'; import { article } from '../../api'; +import { + ARTICLE_TAB, + DEFAULT_ARTICLE_TAB, + articleShowsCompoundTab, + readPreferredArticleTab, + writePreferredArticleTab, +} from '../../pages/artikel/articleRoute'; import { notifyError } from '../../utils/notify'; import { PanelTile } from '../panels'; import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from '../panels/panelTileLayout'; -const TAB = { - history: 'history', - supplier: 'supplier', - seo: 'seo', - label: 'label', - attributes: 'attributes', - compound: 'compound', - settings: 'settings', -}; - const ALWAYS_TABS = [ - { id: TAB.history, label: 'History', color: '#fed7aa', Icon: HistoryIcon }, - { id: TAB.supplier, label: 'Supplier', color: '#bbf7d0', Icon: LocalShippingIcon }, - { id: TAB.seo, label: 'SEO & Beschreibung', color: '#99f6e4', Icon: ManageSearchIcon }, - { id: TAB.label, label: 'Label', color: '#fde68a', Icon: LabelIcon }, - { id: TAB.attributes, label: 'Attribute und Merkmale', color: '#bfdbfe', Icon: CategoryIcon }, + { id: ARTICLE_TAB.history, label: 'History', color: '#fed7aa', Icon: HistoryIcon }, + { id: ARTICLE_TAB.supplier, label: 'Supplier', color: '#bbf7d0', Icon: LocalShippingIcon }, + { + id: ARTICLE_TAB.seo, + label: 'SEO & Beschreibung', + color: '#99f6e4', + Icon: ManageSearchIcon, + }, + { id: ARTICLE_TAB.label, label: 'Label', color: '#fde68a', Icon: LabelIcon }, + { + id: ARTICLE_TAB.attributes, + label: 'Attribute und Merkmale', + color: '#bfdbfe', + Icon: CategoryIcon, + }, ]; const COMPOUND_TAB = { - id: TAB.compound, + id: ARTICLE_TAB.compound, label: 'Compound Editor', color: '#e9d5ff', Icon: AccountTreeIcon, }; const SETTINGS_TAB = { - id: TAB.settings, + id: ARTICLE_TAB.settings, label: 'Einstellungen', color: '#fca5a5', Icon: SettingsIcon, }; -function isCompoundArticle(article) { - return ( - article?.articleListCount > 0 || - article?.optionsCount > 0 || - article?.childCount > 0 || - article?.fatherArticleId != null - ); -} - function visibleTabs(article) { const tabs = [...ALWAYS_TABS]; - if (isCompoundArticle(article)) { + if (articleShowsCompoundTab(article)) { tabs.push(COMPOUND_TAB); } tabs.push(SETTINGS_TAB); @@ -86,7 +86,7 @@ function PanelValue({ label, children }) { function Placeholder({ label }) { return ( - {label} — noch nicht verfügbar. + {label} ); } @@ -94,21 +94,24 @@ function Placeholder({ label }) { class ArticleTileTab extends Component { constructor(props) { super(props); - this.state = { tab: TAB.seo, busy: false }; + this.state = { + busy: false, + preferredTab: readPreferredArticleTab(), + }; this.handleTabChange = this.handleTabChange.bind(this); + this.handlePinDefault = this.handlePinDefault.bind(this); this.handleDeleteArticle = this.handleDeleteArticle.bind(this); } - componentDidUpdate(prevProps) { - if (prevProps.article === this.props.article) return; - const tabs = visibleTabs(this.props.article); - if (!tabs.some((row) => row.id === this.state.tab)) { - this.setState({ tab: TAB.seo }); - } + handleTabChange(_event, tab) { + this.props.onTabChange?.(tab); } - handleTabChange(_event, tab) { - this.setState({ tab }); + handlePinDefault() { + const { tab } = this.props; + if (!tab) return; + writePreferredArticleTab(tab); + this.setState({ preferredTab: tab }); } async handleDeleteArticle() { @@ -159,20 +162,20 @@ class ArticleTileTab extends Component { } renderPanel() { - switch (this.state.tab) { - case TAB.history: + switch (this.props.tab) { + case ARTICLE_TAB.history: return ; - case TAB.supplier: + case ARTICLE_TAB.supplier: return ; - case TAB.seo: + case ARTICLE_TAB.seo: return this.renderSeo(); - case TAB.label: + case ARTICLE_TAB.label: return ; - case TAB.attributes: + case ARTICLE_TAB.attributes: return ; - case TAB.compound: + case ARTICLE_TAB.compound: return ; - case TAB.settings: + case ARTICLE_TAB.settings: return this.renderSettings(); default: return null; @@ -180,11 +183,24 @@ class ArticleTileTab extends Component { } render() { - const { article: articleData, wide = 3, minHeight = 200, sx, tileKey } = this.props; + const { + article: articleData, + wide = 3, + minHeight = 200, + sx, + tileKey, + tab = DEFAULT_ARTICLE_TAB, + } = this.props; + const { preferredTab } = this.state; const tabs = visibleTabs(articleData); - const { tab } = this.state; - const active = tabs.find((row) => row.id === tab) ?? ALWAYS_TABS.find((row) => row.id === TAB.seo); + const active = + tabs.find((row) => row.id === tab) ?? + ALWAYS_TABS.find((row) => row.id === DEFAULT_ARTICLE_TAB); const activeColor = active?.color; + const effectivePreferred = tabs.some((row) => row.id === preferredTab) + ? preferredTab + : DEFAULT_ARTICLE_TAB; + const isPinnedDefault = tab === effectivePreferred; return ( - - {tabs.map((row) => { - const selected = tab === row.id; - const { Icon } = row; - return ( - } - sx={{ - minHeight: 56, - minWidth: 64, - py: 0.5, - px: 1.25, - border: 'none', - borderBottom: 'none', - borderRadius: '8px 8px 0 0', - textTransform: 'none', - bgcolor: row.color, - opacity: selected ? 1 : 0.72, - color: 'text.primary', - zIndex: selected ? 2 : 1, - boxShadow: 'none', - '&.Mui-selected': { - color: 'text.primary', + + {tabs.map((row) => { + const selected = tab === row.id; + const { Icon } = row; + return ( + } + sx={{ + minHeight: 56, + minWidth: 64, + py: 0.5, + px: 1.25, + border: 'none', borderBottom: 'none', - }, - }} - /> - ); - })} - + borderRadius: '8px 8px 0 0', + textTransform: 'none', + bgcolor: row.color, + opacity: selected ? 1 : 0.72, + color: 'text.primary', + zIndex: selected ? 2 : 1, + boxShadow: 'none', + '&.Mui-selected': { + color: 'text.primary', + borderBottom: 'none', + }, + }} + /> + ); + })} + + + + + @@ -908,7 +916,7 @@ export default function ArticleView({ article: articleData, onArticleUpdated }) {articleData.fatherSku ? ( {articleData.fatherSku} diff --git a/src/pages/artikel/articleRoute.js b/src/pages/artikel/articleRoute.js index d641367..95f85a5 100644 --- a/src/pages/artikel/articleRoute.js +++ b/src/pages/artikel/articleRoute.js @@ -1,11 +1,108 @@ +export const ARTICLE_TAB = { + history: 'history', + supplier: 'supplier', + seo: 'seo', + label: 'label', + attributes: 'attributes', + compound: 'compound', + settings: 'settings', +}; + +/** Factory fallback when nothing is stored in localStorage. */ +export const DEFAULT_ARTICLE_TAB = ARTICLE_TAB.seo; + +const PREFERRED_ARTICLE_TAB_KEY = 'dashfront:article-default-tab'; + +const ALL_ARTICLE_TAB_IDS = new Set(Object.values(ARTICLE_TAB)); + +const BASE_ARTICLE_TAB_IDS = [ + ARTICLE_TAB.history, + ARTICLE_TAB.supplier, + ARTICLE_TAB.seo, + ARTICLE_TAB.label, + ARTICLE_TAB.attributes, +]; + export function articleSku(article) { if (!article) return null; const sku = article.sku?.trim(); return sku || null; } -export function artikelDetailPath(article) { - const sku = articleSku(article); - if (!sku) return null; - return `/artikel/${encodeURIComponent(sku)}`; +export function articleShowsCompoundTab(article) { + return ( + article?.articleListCount > 0 || + article?.optionsCount > 0 || + article?.childCount > 0 || + article?.fatherArticleId != null + ); +} + +export function articleTabIds(article) { + const ids = [...BASE_ARTICLE_TAB_IDS]; + if (articleShowsCompoundTab(article)) { + ids.push(ARTICLE_TAB.compound); + } + ids.push(ARTICLE_TAB.settings); + return ids; +} + +export function isArticleTabId(tab) { + return ALL_ARTICLE_TAB_IDS.has(tab); +} + +export function readPreferredArticleTab() { + try { + const raw = localStorage.getItem(PREFERRED_ARTICLE_TAB_KEY); + if (raw && isArticleTabId(raw)) return raw; + } catch { + // ignore quota / private mode + } + return DEFAULT_ARTICLE_TAB; +} + +export function writePreferredArticleTab(tab) { + if (!isArticleTabId(tab)) return; + try { + localStorage.setItem(PREFERRED_ARTICLE_TAB_KEY, tab); + } catch { + // ignore quota / private mode + } +} + +/** Preferred default, clamped to tabs visible for this article. */ +export function preferredArticleTab(article) { + const preferred = readPreferredArticleTab(); + const ids = articleTabIds(article); + if (ids.includes(preferred)) return preferred; + return DEFAULT_ARTICLE_TAB; +} + +export function resolveArticleTab(tabParam, article) { + const ids = articleTabIds(article); + if (tabParam && ids.includes(tabParam)) return tabParam; + return preferredArticleTab(article); +} + +/** + * @param {object|string} articleOrSku + * @param {string} [tab] omitted → preferred default from localStorage + */ +export function artikelDetailPath(articleOrSku, tab) { + const sku = + typeof articleOrSku === 'string' + ? articleOrSku.trim() || null + : articleSku(articleOrSku); + if (!sku) return null; + + let segment; + if (isArticleTabId(tab)) { + segment = tab; + } else if (articleOrSku && typeof articleOrSku === 'object') { + segment = preferredArticleTab(articleOrSku); + } else { + segment = readPreferredArticleTab(); + } + + return `/artikel/${encodeURIComponent(sku)}/${segment}`; } diff --git a/src/pages/artikel/index.jsx b/src/pages/artikel/index.jsx index ac4f5bb..eb79250 100644 --- a/src/pages/artikel/index.jsx +++ b/src/pages/artikel/index.jsx @@ -29,6 +29,7 @@ import { parseFilter, } from './searchFormat'; import { preserveSearchFocus } from './searchFocus'; +import { artikelDetailPath } from './articleRoute'; import withSearchParams from './withSearchParams'; const resultRowSx = { @@ -430,7 +431,7 @@ class ArtikelSearchPage extends Component { onMouseDown={(e) => e.preventDefault()} onClick={() => { this.persistQuery(); - this.props.navigate(`/artikel/${encodeURIComponent(row.sku)}`); + this.props.navigate(artikelDetailPath(row)); }} sx={resultRowSx} > diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx index d68e54f..66241ab 100644 --- a/src/pages/artikel/sku.jsx +++ b/src/pages/artikel/sku.jsx @@ -8,9 +8,13 @@ import { ArticleView } from '../../components/article'; import BackendError from '../../components/BackendError'; import { requireObject } from '../../utils/apiShape'; import { backendErrorFrom, isBackendFailure } from '../../utils/backendError'; +import { + artikelDetailPath, + resolveArticleTab, +} from './articleRoute'; export default function ArtikelSkuPage() { - const { sku: skuParam } = useParams(); + const { sku: skuParam, tab: tabParam } = useParams(); const navigate = useNavigate(); const sku = skuParam ? decodeURIComponent(skuParam) : ''; const [data, setData] = useState(null); @@ -69,6 +73,14 @@ export default function ArtikelSkuPage() { }; }, [sku, reloadKey]); + useEffect(() => { + if (!data) return; + const resolved = resolveArticleTab(tabParam, data); + if (tabParam !== resolved) { + navigate(artikelDetailPath(data, resolved), { replace: true }); + } + }, [data, tabParam, navigate]); + if (loading) { return ( @@ -95,5 +107,17 @@ export default function ArtikelSkuPage() { if (!data) return null; - return ; + const tab = resolveArticleTab(tabParam, data); + if (tabParam !== tab) return null; + + return ( + { + navigate(artikelDetailPath(data, nextTab)); + }} + onArticleUpdated={setData} + /> + ); }