diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx index 915f37388d81..1c44f6342bbc 100644 --- a/src/frontend/src/components/nav/PageDetail.tsx +++ b/src/frontend/src/components/nav/PageDetail.tsx @@ -1,9 +1,10 @@ -import { Group, Paper, Space, Stack, Text } from '@mantine/core'; +import { ActionIcon, Group, Paper, Space, Stack, Text } from '@mantine/core'; import { StylishText } from '@lib/components/StylishText'; import { useInvenTreeHotkeys } from '@lib/functions/Events'; import { shortenString } from '@lib/functions/String'; import { t } from '@lingui/core/macro'; +import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; import { Fragment, type ReactNode, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature'; @@ -15,6 +16,13 @@ import type { PrimaryActionUIFeature } from '../plugins/PluginUIFeatureTypes'; import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList'; import PageTitle from './PageTitle'; +export interface NextPrevInterface { + hasPrev: boolean; + hasNext: boolean; + onPrev: () => void; + onNext: () => void; +} + interface PageDetailInterface { title?: string; icon?: ReactNode; @@ -28,6 +36,8 @@ interface PageDetailInterface { actions?: ReactNode[]; editAction?: () => void; editEnabled?: boolean; + /** Optional previous/next sibling navigation, e.g. from useNextPrevInstance */ + nextPrev?: NextPrevInterface; } /** @@ -48,7 +58,8 @@ export function PageDetail({ breadcrumbAction, actions, editAction, - editEnabled + editEnabled, + nextPrev }: Readonly) { const userSettings = useUserSettingsState(); const navigate = useNavigate(); @@ -66,6 +77,22 @@ export function PageDetail({ editAction?.(); } } + ], + [ + 'alt+ArrowLeft', + t`Previous`, + (event) => { + if (event.repeat) return; + if (nextPrev?.hasPrev) nextPrev.onPrev(); + } + ], + [ + 'alt+ArrowRight', + t`Next`, + (event) => { + if (event.repeat) return; + if (nextPrev?.hasNext) nextPrev.onNext(); + } ] ]); useActionHotkeys(actions); @@ -184,13 +211,34 @@ export function PageDetail({ )} - {computedActions && ( - - {computedActions.map((action, idx) => ( + + {nextPrev && ( + + + + + + + + + )} + {computedActions && + computedActions.map((action, idx) => ( {action} ))} - - )} + diff --git a/src/frontend/src/hooks/UseNextPrevInstance.tsx b/src/frontend/src/hooks/UseNextPrevInstance.tsx new file mode 100644 index 000000000000..7b253cd473fa --- /dev/null +++ b/src/frontend/src/hooks/UseNextPrevInstance.tsx @@ -0,0 +1,87 @@ +import { apiUrl } from '@lib/functions/Api'; +import { useCallback, useMemo } from 'react'; +import { useApi } from '../contexts/ApiContext'; +import { useLocalLibState } from '../states/LocalLibState'; + +/** + * Hook for resolving the previous/next sibling instance for a detail page. + * + * If a matching list-navigation context was captured when the user clicked + * into this record (see InvenTreeTable row click / useLocalLibState.setListNavContext), + * prev/next are resolved as O(1) lookups into that exact filtered/ordered list. + * + * Falls back to an ordering-aware pk__gt/pk__lt API query when no context is + * available (direct link, bookmark, or standalone plugin usage). + * + * If a resolved neighbor pk no longer exists (404), it is dropped from the + * stored context and the next available neighbor is fetched instead. + */ +export function useNextPrevInstance({ + endpoint, + pk, + ordering +}: { + endpoint: string; + pk?: string | number; + ordering?: string; +}) { + const api = useApi(); + + const ctx = useLocalLibState((s) => s.listNavContexts[endpoint]); + const dropPk = useLocalLibState((s) => s.dropListNavPk); + + const pkNum = pk != null ? Number(pk) : undefined; + + const fromContext = useMemo(() => { + if (!ctx || pkNum == null) return null; + const idx = ctx.pks.indexOf(pkNum); + if (idx === -1) return null; + return { + prevPk: idx > 0 ? ctx.pks[idx - 1] : undefined, + nextPk: idx < ctx.pks.length - 1 ? ctx.pks[idx + 1] : undefined + }; + }, [ctx, pkNum]); + + const fetchNeighbor = useCallback( + async (direction: 'prev' | 'next'): Promise => { + const filter = + direction === 'prev' ? { pk__lt: pkNum } : { pk__gt: pkNum }; + const order = + direction === 'prev' ? `-${ordering ?? 'pk'}` : (ordering ?? 'pk'); + const res = await api.get(apiUrl(endpoint), { + params: { ...filter, ordering: order, limit: 1 } + }); + return res.data?.results?.[0]?.pk; + }, + [api, endpoint, pkNum, ordering] + ); + + const goTo = useCallback( + async (direction: 'prev' | 'next'): Promise => { + const candidate = fromContext + ? direction === 'prev' + ? fromContext.prevPk + : fromContext.nextPk + : await fetchNeighbor(direction); + + if (candidate == null) return undefined; + + try { + await api.get(apiUrl(endpoint, candidate)); + return candidate; + } catch { + // Stale reference (deleted / filtered out since context was captured) + if (fromContext) dropPk(endpoint, candidate); + return fetchNeighbor(direction); + } + }, + [fromContext, fetchNeighbor, api, endpoint, dropPk] + ); + + return { + hasPrev: fromContext ? fromContext.prevPk != null : true, + hasNext: fromContext ? fromContext.nextPk != null : true, + goToPrev: () => goTo('prev'), + goToNext: () => goTo('next') + }; +} diff --git a/src/frontend/src/states/LocalLibState.tsx b/src/frontend/src/states/LocalLibState.tsx new file mode 100644 index 000000000000..a6ba441a54a5 --- /dev/null +++ b/src/frontend/src/states/LocalLibState.tsx @@ -0,0 +1,81 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export const useLocalLibState = create()( + persist( + (set, get) => ({ + detailDrawerStack: 0, + addDetailDrawer: (value) => { + set({ + detailDrawerStack: + value === false ? 0 : get().detailDrawerStack + value + }); + }, + hotkeys: {}, + addHotkeys: (hotkeys) => { + const newHotkeys = { ...get().hotkeys }; + for (const [ref, details] of hotkeys) { + newHotkeys[ref] = details; + } + set({ hotkeys: newHotkeys }); + }, + removeHotkeys: (hotkeys) => { + const newHotkeys = { ...get().hotkeys }; + for (const ref of hotkeys) { + delete newHotkeys[ref]; + } + set({ hotkeys: newHotkeys }); + }, + + // Captured pk order for the list a user last navigated *from*, + // keyed by API endpoint. Used to power next/prev navigation on + // detail pages within the same filtered/ordered context. + listNavContexts: {}, + setListNavContext: (endpoint, pks) => { + set({ + listNavContexts: { + ...get().listNavContexts, + [endpoint]: { pks } + } + }); + }, + dropListNavPk: (endpoint, pk) => { + const existing = get().listNavContexts[endpoint]; + if (!existing) return; + set({ + listNavContexts: { + ...get().listNavContexts, + [endpoint]: { pks: existing.pks.filter((p) => p !== pk) } + } + }); + } + }), + + { + name: 'session-settings-inventreedb_lib', + // listNavContexts is short-lived navigation state, not a persisted + // user preference - exclude it from localStorage persistence so + // stale pk lists don't survive across sessions. + partialize: (state) => { + const { listNavContexts, ...rest } = state; + return rest; + } + } + ) +); + +export interface ListNavContext { + pks: number[]; +} + +export interface LocalLibStateProps { + detailDrawerStack: number; + addDetailDrawer: (value: number | false) => void; + hotkeys: Record; + addHotkeys: (hotkeys: [string, string][]) => void; + removeHotkeys: (hotkeys: string[]) => void; + + listNavContexts: Record; + setListNavContext: (endpoint: string, pks: number[]) => void; + dropListNavPk: (endpoint: string, pk: number) => void; +}