@igstack/app-catalog-frontend-core 6.10.0 → 6.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -456,193 +456,181 @@ export function AppDetails({
456
456
  : undefined,
457
457
  render: () => (
458
458
  <div className="behind-panel">
459
- <div className="behind-layout">
460
- <div className="behind-main">
461
- <CommunitySection
462
- ref={knowMoreRef}
463
- appSlug={app.slug}
464
- appName={app.displayName}
465
- claims={app.claims}
466
- />
467
- </div>
459
+ <CommunitySection
460
+ ref={knowMoreRef}
461
+ appSlug={app.slug}
462
+ appName={app.displayName}
463
+ claims={app.claims}
464
+ />
468
465
 
469
- <div className="behind-aside">
470
- {(isAdmin ||
471
- sourceUrls.length > 0 ||
472
- app.createdAt ||
473
- app.freshness?.lastCheckedAt) && (
474
- <Group title="Kept up to date from">
475
- {isAdmin ? (
476
- <>
477
- <ul className="space-y-2">
478
- {displaySources.map((url, index) => {
479
- const isDraft =
480
- draftSource !== null && index === sourceUrls.length
481
- return (
482
- <li
483
- key={isDraft ? 'draft' : `${index}-${url}`}
484
- className="group flex items-center gap-2 text-xs"
485
- >
486
- <span className="text-muted-foreground shrink-0 tabular-nums">
487
- {index + 1}.
488
- </span>
489
- <InlineEditableField
490
- value={url}
491
- initialEditMode={isDraft}
492
- onCancel={
493
- isDraft
494
- ? () => setDraftSource(null)
495
- : undefined
496
- }
497
- onSave={(newUrl) => {
498
- if (isDraft) {
499
- setDraftSource(null)
500
- if (newUrl) {
501
- updateApp.mutate({
502
- id: app.id,
503
- data: {
504
- sources: [...sourceUrls, newUrl],
505
- },
506
- })
507
- }
508
- } else {
509
- const next = [...sourceUrls]
510
- next[index] = newUrl
511
- updateApp.mutate({
512
- id: app.id,
513
- data: { sources: next.filter(Boolean) },
514
- })
515
- }
516
- }}
517
- placeholder="https://..."
518
- viewClassName="flex-1 min-w-0"
519
- renderView={(val) =>
520
- val ? (
521
- <a
522
- href={val}
523
- target="_blank"
524
- rel="noopener noreferrer"
525
- className="hover:text-primary inline-flex items-center gap-1 truncate"
526
- >
527
- {displayUrl(val)}
528
- <ExternalLink className="size-3 shrink-0" />
529
- </a>
530
- ) : (
531
- <span className="text-muted-foreground">
532
- —
533
- </span>
534
- )
535
- }
536
- />
537
- {!isDraft && pulseByUrl.has(url) && (
538
- <SourcePulse
539
- pulse={pulseByUrl.get(url)!}
540
- className="ml-auto"
541
- />
542
- )}
543
- {!isDraft && (
544
- <Button
545
- type="button"
546
- variant="ghost"
547
- size="icon-sm"
548
- aria-label="Remove source"
549
- className="shrink-0 text-muted-foreground hover:text-destructive"
550
- onClick={() => {
551
- const next = sourceUrls.filter(
552
- (_, i) => i !== index,
553
- )
554
- updateApp.mutate({
555
- id: app.id,
556
- data: { sources: next },
557
- })
558
- }}
559
- >
560
- <Trash2 className="size-3.5" />
561
- </Button>
562
- )}
563
- </li>
564
- )
565
- })}
566
- </ul>
567
- <Button
568
- type="button"
569
- variant="ghost"
570
- size="sm"
571
- className="mt-2 gap-1 text-muted-foreground"
572
- onClick={() => setDraftSource('')}
573
- >
574
- <Plus className="size-3.5" />
575
- Add source
576
- </Button>
577
- </>
578
- ) : (
579
- <ul className="space-y-2">
580
- {sourceUrls.map((url, index) => (
581
- // `group` so hovering anywhere on the row fades that source's
582
- // track in — the mark sits in a right-hand column, and row
583
- // hover is what ties the two ends of a wide row together.
466
+ {(isAdmin ||
467
+ sourceUrls.length > 0 ||
468
+ app.createdAt ||
469
+ app.freshness?.lastCheckedAt) && (
470
+ <Group title="Kept up to date from">
471
+ {isAdmin ? (
472
+ <>
473
+ <ul className="space-y-2">
474
+ {displaySources.map((url, index) => {
475
+ const isDraft =
476
+ draftSource !== null && index === sourceUrls.length
477
+ return (
584
478
  <li
585
- key={index}
479
+ key={isDraft ? 'draft' : `${index}-${url}`}
586
480
  className="group flex items-center gap-2 text-xs"
587
481
  >
588
482
  <span className="text-muted-foreground shrink-0 tabular-nums">
589
483
  {index + 1}.
590
484
  </span>
591
- {url ? (
592
- <a
593
- href={url}
594
- target="_blank"
595
- rel="noopener noreferrer"
596
- className="hover:text-primary inline-flex min-w-0 items-center gap-1 truncate"
597
- >
598
- {displayUrl(url)}
599
- <ExternalLink className="size-3 shrink-0" />
600
- </a>
601
- ) : (
602
- <span className="text-muted-foreground">—</span>
603
- )}
604
- {pulseByUrl.has(url) && (
485
+ <InlineEditableField
486
+ value={url}
487
+ initialEditMode={isDraft}
488
+ onCancel={
489
+ isDraft ? () => setDraftSource(null) : undefined
490
+ }
491
+ onSave={(newUrl) => {
492
+ if (isDraft) {
493
+ setDraftSource(null)
494
+ if (newUrl) {
495
+ updateApp.mutate({
496
+ id: app.id,
497
+ data: { sources: [...sourceUrls, newUrl] },
498
+ })
499
+ }
500
+ } else {
501
+ const next = [...sourceUrls]
502
+ next[index] = newUrl
503
+ updateApp.mutate({
504
+ id: app.id,
505
+ data: { sources: next.filter(Boolean) },
506
+ })
507
+ }
508
+ }}
509
+ placeholder="https://..."
510
+ viewClassName="flex-1 min-w-0"
511
+ renderView={(val) =>
512
+ val ? (
513
+ <a
514
+ href={val}
515
+ target="_blank"
516
+ rel="noopener noreferrer"
517
+ className="hover:text-primary inline-flex items-center gap-1 truncate"
518
+ >
519
+ {displayUrl(val)}
520
+ <ExternalLink className="size-3 shrink-0" />
521
+ </a>
522
+ ) : (
523
+ <span className="text-muted-foreground">—</span>
524
+ )
525
+ }
526
+ />
527
+ {!isDraft && pulseByUrl.has(url) && (
605
528
  <SourcePulse
606
529
  pulse={pulseByUrl.get(url)!}
607
530
  className="ml-auto"
608
531
  />
609
532
  )}
533
+ {!isDraft && (
534
+ <Button
535
+ type="button"
536
+ variant="ghost"
537
+ size="icon-sm"
538
+ aria-label="Remove source"
539
+ className="shrink-0 text-muted-foreground hover:text-destructive"
540
+ onClick={() => {
541
+ const next = sourceUrls.filter(
542
+ (_, i) => i !== index,
543
+ )
544
+ updateApp.mutate({
545
+ id: app.id,
546
+ data: { sources: next },
547
+ })
548
+ }}
549
+ >
550
+ <Trash2 className="size-3.5" />
551
+ </Button>
552
+ )}
610
553
  </li>
611
- ))}
612
- </ul>
613
- )}
614
- {datesLine && (
615
- <p className="behind-dates" title={datesTitle}>
616
- {datesLine}
617
- </p>
618
- )}
619
- </Group>
554
+ )
555
+ })}
556
+ </ul>
557
+ <Button
558
+ type="button"
559
+ variant="ghost"
560
+ size="sm"
561
+ className="mt-2 gap-1 text-muted-foreground"
562
+ onClick={() => setDraftSource('')}
563
+ >
564
+ <Plus className="size-3.5" />
565
+ Add source
566
+ </Button>
567
+ </>
568
+ ) : (
569
+ <ul className="space-y-2">
570
+ {sourceUrls.map((url, index) => (
571
+ // `group` so hovering anywhere on the row fades that source's
572
+ // track in — the mark sits in a right-hand column, and row
573
+ // hover is what ties the two ends of a wide row together.
574
+ <li
575
+ key={index}
576
+ className="group flex items-center gap-2 text-xs"
577
+ >
578
+ <span className="text-muted-foreground shrink-0 tabular-nums">
579
+ {index + 1}.
580
+ </span>
581
+ {url ? (
582
+ <a
583
+ href={url}
584
+ target="_blank"
585
+ rel="noopener noreferrer"
586
+ className="hover:text-primary inline-flex min-w-0 items-center gap-1 truncate"
587
+ >
588
+ {displayUrl(url)}
589
+ <ExternalLink className="size-3 shrink-0" />
590
+ </a>
591
+ ) : (
592
+ <span className="text-muted-foreground">—</span>
593
+ )}
594
+ {pulseByUrl.has(url) && (
595
+ <SourcePulse
596
+ pulse={pulseByUrl.get(url)!}
597
+ className="ml-auto"
598
+ />
599
+ )}
600
+ </li>
601
+ ))}
602
+ </ul>
603
+ )}
604
+ {datesLine && (
605
+ <p className="behind-dates" title={datesTitle}>
606
+ {datesLine}
607
+ </p>
620
608
  )}
609
+ </Group>
610
+ )}
621
611
 
622
- {(app.aiPrompt || app.aiMemory) && (
623
- <Group title="How the AI maintains this card">
624
- {app.aiPrompt && (
625
- <>
626
- <p className="behind-ai__label">Prompt</p>
627
- <p className="behind-ai">{app.aiPrompt}</p>
628
- </>
629
- )}
630
- {app.aiMemory && (
631
- <>
632
- <p className="behind-ai__label">Memory</p>
633
- <p className="behind-ai">{app.aiMemory}</p>
634
- </>
635
- )}
636
- </Group>
612
+ {(app.aiPrompt || app.aiMemory) && (
613
+ <Group title="How the AI maintains this card">
614
+ {app.aiPrompt && (
615
+ <>
616
+ <p className="behind-ai__label">Prompt</p>
617
+ <p className="behind-ai">{app.aiPrompt}</p>
618
+ </>
619
+ )}
620
+ {app.aiMemory && (
621
+ <>
622
+ <p className="behind-ai__label">Memory</p>
623
+ <p className="behind-ai">{app.aiMemory}</p>
624
+ </>
637
625
  )}
626
+ </Group>
627
+ )}
638
628
 
639
- <div className="behind-card-source">
640
- <ResourceDetailCardSource
641
- appSlug={app.slug}
642
- appName={app.displayName}
643
- />
644
- </div>
645
- </div>
629
+ <div className="behind-card-source">
630
+ <ResourceDetailCardSource
631
+ appSlug={app.slug}
632
+ appName={app.displayName}
633
+ />
646
634
  </div>
647
635
  </div>
648
636
  ),
@@ -6,12 +6,17 @@ import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
6
6
  import { AppCatalogGrid } from '../catalog/AppCatalogGrid'
7
7
  import { AttributionFooter } from '../catalog/AttributionFooter'
8
8
 
9
- // The detail card (and the markdown stack it renders) loads on first open, not
10
- // with the catalog grid every visitor sees first (#120).
9
+ // The detail card (and the markdown stack it renders) stays out of the bundle
10
+ // every visitor loads first (#120). The page fetches it after its first paint,
11
+ // so the first click on a row does not wait for the chunk.
12
+ const loadAppDetailPanel = () => import('../catalog/AppDetailPanel')
13
+
14
+ /** Resolves once the detail card's chunk is loaded. */
15
+ export const preloadAppDetailPanel = (): Promise<void> =>
16
+ loadAppDetailPanel().then(() => undefined)
17
+
11
18
  const AppDetailPanel = lazy(() =>
12
- import('../catalog/AppDetailPanel').then((m) => ({
13
- default: m.AppDetailPanel,
14
- })),
19
+ loadAppDetailPanel().then((m) => ({ default: m.AppDetailPanel })),
15
20
  )
16
21
 
17
22
  export function AppCatalogPage({
@@ -84,6 +89,10 @@ export function AppCatalogPage({
84
89
  [rootResources, filterState.showDeprecated],
85
90
  )
86
91
 
92
+ useEffect(() => {
93
+ void preloadAppDetailPanel()
94
+ }, [])
95
+
87
96
  // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level
88
97
  // (rootResources === 0). That fingerprints a frontend/backend-core version skew
89
98
  // or a stale service worker — NOT a normal empty search/filter (those still have
@@ -23,11 +23,29 @@ export function PwaAutoUpdateProvider({
23
23
  children,
24
24
  }: PwaAutoUpdateProviderProps) {
25
25
  const controllerRef = useRef<PwaAutoUpdateController | undefined>(undefined)
26
+ const handleRef = useRef(handle)
26
27
 
28
+ // A new handle (the registration arriving) goes to the running controller.
29
+ // Declared first, so the effect below reads the current handle.
27
30
  useEffect(() => {
28
- if (!handle) return
31
+ handleRef.current = handle
32
+ if (handle) controllerRef.current?.updateHandle(handle)
33
+ }, [handle])
34
+
35
+ // One controller for as long as there is a handle and the option values
36
+ // hold. Keyed on values, not identities: rebuilding it drops its pending
37
+ // visibility check and its idle tracking.
38
+ const hasHandle = handle !== undefined
39
+ const { idleTimeoutMs, minCheckIntervalMs, debug } = options ?? {}
40
+ useEffect(() => {
41
+ const current = handleRef.current
42
+ if (!current) return
29
43
 
30
- const controller = new PwaAutoUpdateController(handle, options)
44
+ const controller = new PwaAutoUpdateController(current, {
45
+ idleTimeoutMs,
46
+ minCheckIntervalMs,
47
+ debug,
48
+ })
31
49
  controllerRef.current = controller
32
50
  controller.start()
33
51
 
@@ -35,14 +53,7 @@ export function PwaAutoUpdateProvider({
35
53
  controller.destroy()
36
54
  controllerRef.current = undefined
37
55
  }
38
- }, [handle, options])
39
-
40
- // Update handle when registration arrives async
41
- useEffect(() => {
42
- if (handle && controllerRef.current) {
43
- controllerRef.current.updateHandle(handle)
44
- }
45
- }, [handle])
56
+ }, [hasHandle, idleTimeoutMs, minCheckIntervalMs, debug])
46
57
 
47
58
  const contextValue = useMemo<PwaAutoUpdateContextValue>(
48
59
  () => ({
@@ -3,9 +3,12 @@ import type { QueryClient } from '@tanstack/react-query'
3
3
 
4
4
  // Dev-only: `import.meta.env.DEV` is false in production builds, so Rollup drops
5
5
  // the import() and neither devtools package reaches the shipped bundle (#120).
6
- const DevtoolsPanels = import.meta.env.DEV
7
- ? lazy(() => import('./DevtoolsPanels'))
8
- : null
6
+ // Off under vitest too: nobody looks at them there, and every test file would
7
+ // otherwise transform both packages on its first mount.
8
+ const DevtoolsPanels =
9
+ import.meta.env.DEV && import.meta.env.MODE !== 'test'
10
+ ? lazy(() => import('./DevtoolsPanels'))
11
+ : null
9
12
 
10
13
  export function Devtools({ client }: { client: QueryClient }) {
11
14
  if (!DevtoolsPanels) return null