1616 * Registry-driven: the catalog types come from `GET /catalogs`. The "concept"
1717 * type links out to the dedicated Taxonomy Manager.
1818 */
19- import React , { useCallback , useEffect , useMemo , useState } from 'react' ;
19+ import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
2020import { useSearchParams } from 'react-router-dom' ;
2121import { Database , ArrowLeft , Info as InfoIcon , Edit3 , History as HistoryIcon , ExternalLink , Trash2 , RotateCcw , List as ListIcon , GitBranch } from 'lucide-react' ;
2222import PageHeader from '../../components/ui/PageHeader' ;
@@ -48,6 +48,7 @@ import { ScopeBadge } from '../../components/catalog/ScopeBadge';
4848import {
4949 listCatalogTypes ,
5050 listCatalogItems ,
51+ getCatalogItem ,
5152 createCatalogItem ,
5253 updateCatalogItem ,
5354 deleteCatalogItem ,
@@ -197,6 +198,16 @@ export const CatalogWorkspace: React.FC = () => {
197198 const PAGE_SIZE = 50 ;
198199 const isMineScope = scopeFilter === 'mine' ;
199200
201+ // Backend pagination cursor (how many items the server has handed us),
202+ // tracked separately from `items.length` because deep-link injection (see
203+ // the effect below) prepends an out-of-page item. Using items.length as the
204+ // next offset in that case would skip a real item, so load/loadMore advance
205+ // this ref from the server response sizes instead.
206+ const offsetRef = useRef ( 0 ) ;
207+ // Item ids that 404/403'd on the single-item fetch — don't keep retrying the
208+ // injection on every list mutation. Cleared on catalog-type change.
209+ const knownMissingRef = useRef < Set < string > > ( new Set ( ) ) ;
210+
200211 // Server-side facet params (kind for concepts, class for anatomy/…).
201212 // Stringified for a stable dependency so client-side facet toggles don't
202213 // trigger a refetch — only server-facet changes do.
@@ -217,9 +228,11 @@ export const CatalogWorkspace: React.FC = () => {
217228 } ) ;
218229 setItems ( resp . items ) ;
219230 setTotal ( resp . total ) ;
231+ offsetRef . current = resp . items . length ;
220232 } catch {
221233 setItems ( [ ] ) ;
222234 setTotal ( 0 ) ;
235+ offsetRef . current = 0 ;
223236 } finally {
224237 setItemsLoading ( false ) ;
225238 }
@@ -238,21 +251,61 @@ export const CatalogWorkspace: React.FC = () => {
238251 ...catalogFilter . serverParams ,
239252 include : 'relations' ,
240253 limit : PAGE_SIZE ,
241- offset : items . length ,
254+ offset : offsetRef . current ,
255+ } ) ;
256+ offsetRef . current += resp . items . length ;
257+ // Dedup against what we already show: a deep-linked item is injected at
258+ // the top of the list, so its natural copy would reappear on its real
259+ // page — drop it there to avoid showing the card twice.
260+ setItems ( ( prev ) => {
261+ const seen = new Set ( prev . map ( ( it ) => String ( it . id ) ) ) ;
262+ const fresh = resp . items . filter ( ( it ) => ! seen . has ( String ( it . id ) ) ) ;
263+ return fresh . length ? [ ...prev , ...fresh ] : prev ;
242264 } ) ;
243- setItems ( ( prev ) => [ ...prev , ...resp . items ] ) ;
244265 } catch {
245266 /* ignore — keep what we have */
246267 } finally {
247268 setLoadingMore ( false ) ;
248269 }
249270 // eslint-disable-next-line react-hooks/exhaustive-deps
250- } , [ activeType , scopeFilter , serverFilterKey , items . length , loadingMore , isMineScope ] ) ;
271+ } , [ activeType , scopeFilter , serverFilterKey , loadingMore , isMineScope ] ) ;
251272
252273 useEffect ( ( ) => {
253274 load ( ) ;
254275 } , [ load ] ) ;
255276
277+ // Deep-link support: when arriving with ?item=Y and Y isn't in the loaded
278+ // page (it lives on a later page), fetch it directly and prepend it so the
279+ // preview + list can render it without the user paging through "Load more".
280+ // Runs after `load` settles and re-checks after every items mutation; the
281+ // presence guard + knownMissingRef keep it from looping or refetching.
282+ useEffect ( ( ) => {
283+ if ( ! activeType || ! itemId || itemsLoading ) return ;
284+ if ( knownMissingRef . current . has ( itemId ) ) return ;
285+ if ( items . some ( ( it ) => String ( it . id ) === itemId ) ) return ;
286+ let cancelled = false ;
287+ ( async ( ) => {
288+ try {
289+ const single = await getCatalogItem ( activeType , itemId ) ;
290+ if ( cancelled || ! single || single . id == null ) return ;
291+ const asItem = single as unknown as CatalogItem ;
292+ setItems ( ( prev ) =>
293+ prev . some ( ( it ) => String ( it . id ) === String ( asItem . id ) )
294+ ? prev
295+ : [ asItem , ...prev ] ,
296+ ) ;
297+ } catch ( e : any ) {
298+ // 404/403 — the item is gone or not visible to this user. Record it so
299+ // we don't refetch on every subsequent list mutation.
300+ const status = e ?. response ?. status ;
301+ if ( status === 404 || status === 403 ) knownMissingRef . current . add ( itemId ) ;
302+ }
303+ } ) ( ) ;
304+ return ( ) => {
305+ cancelled = true ;
306+ } ;
307+ } , [ activeType , itemId , items , itemsLoading ] ) ;
308+
256309 /** In-memory filters: 'mine' ownership + page-search + facet filters, over the loaded items. */
257310 const filteredItems = useMemo ( ( ) => {
258311 let result = items ;
@@ -280,6 +333,7 @@ export const CatalogWorkspace: React.FC = () => {
280333 useEffect ( ( ) => {
281334 setTab ( INFO ) ;
282335 clearAll ( ) ;
336+ knownMissingRef . current . clear ( ) ;
283337 } , [ activeType , clearAll ] ) ;
284338
285339 // Anatomy-only: fetch the anatomy_class concepts that back the (server-side)
0 commit comments