@igstack/app-catalog-frontend-core 0.8.1 → 0.9.0

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.
@@ -80,9 +80,15 @@ export declare function useUpdateApp(): import('@tanstack/react-query').UseMutat
80
80
  accessMaintainerGroupSlugs?: string[] | undefined;
81
81
  accessComments?: string | undefined;
82
82
  extra?: Record<string, unknown> | undefined;
83
+ lastCheckedAt?: string | null | undefined;
84
+ nextCheckAfter?: string | null | undefined;
83
85
  sources?: string[] | {
84
86
  sourceSlug: string;
85
87
  url: string;
86
88
  parseDate: string | null;
87
89
  }[] | undefined;
90
+ freshness?: {
91
+ lastCheckedAt: string | null;
92
+ isStale: boolean;
93
+ } | undefined;
88
94
  }, Error, UpdateAppVariables, unknown>;
@@ -18,6 +18,7 @@ import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
18
18
  import { useAppClickHistory } from "../../hooks/useAppClickHistory.js";
19
19
  import { useKeyboardNavigation } from "../hooks/useKeyboardNavigation.js";
20
20
  import { highlightText } from "../../utils/searchApps.js";
21
+ import { formatRelativeTime } from "../../utils/formatRelativeTime.js";
21
22
  import { TierVariantsSection } from "../components/TierVariantsSection.js";
22
23
  import { SubResourcesSection } from "../components/SubResourcesSection.js";
23
24
  import { getChildResources } from "../../utils/resolveHelpers.js";
@@ -109,7 +110,7 @@ function AppDetails({
109
110
  onAppClick,
110
111
  onClosePanel
111
112
  }) {
112
- var _a, _b;
113
+ var _a, _b, _c;
113
114
  const [isGalleryOpen, setIsGalleryOpen] = React__default.useState(false);
114
115
  const [galleryInitialIndex, setGalleryInitialIndex] = React__default.useState(0);
115
116
  const { approvalMethods, resources: allResources } = useAppCatalogContext();
@@ -422,6 +423,13 @@ function AppDetails({
422
423
  }
423
424
  ) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "—" })
424
425
  ] }, index)) })
426
+ ] }),
427
+ ((_c = app.freshness) == null ? void 0 : _c.lastCheckedAt) && /* @__PURE__ */ jsxs("p", { className: "mt-4 text-xs text-muted-foreground", children: [
428
+ /* @__PURE__ */ jsxs("span", { title: app.freshness.lastCheckedAt, children: [
429
+ "Last checked ",
430
+ formatRelativeTime(app.freshness.lastCheckedAt)
431
+ ] }),
432
+ app.freshness.isStale && /* @__PURE__ */ jsx("span", { className: "italic opacity-75", children: " · may be out of date" })
425
433
  ] })
426
434
  ] }),
427
435
  /* @__PURE__ */ jsx(
@@ -1 +1 @@
1
- {"version":3,"file":"AppCatalogGrid.js","sources":["../../../../../../src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx"],"sourcesContent":["import type {\n GroupingTagDefinition,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport {\n flexRender,\n getCoreRowModel,\n useReactTable,\n} from '@tanstack/react-table'\nimport { AppWindow, ExternalLink, Plus, Trash2, X } from 'lucide-react'\nimport React, { useState } from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { cn } from '~/lib/utils'\nimport type {} from '~/types/table'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n ResizableHandle,\n ResizablePanel,\n ResizablePanelGroup,\n} from '~/ui/resizable'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { useKeyboardNavigation } from '../hooks/useKeyboardNavigation'\nimport { highlightText } from '../../utils/searchApps'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\n\nexport interface AppCatalogGridProps {\n apps: Resource[]\n selectedAppSlug?: string\n groupingDefinition?: GroupingTagDefinition\n onAppClick?: (app: Resource) => void\n /** Whether search is active (affects group sorting) */\n hasSearch?: boolean\n /** Search query for highlighting matches */\n searchQuery?: string\n /** Total count of apps before filtering */\n totalAppsCount?: number\n /** Callback to clear all filters and search */\n onClearFilters?: () => void\n /** Called when the user closes the detail panel (Esc or X) — e.g. to navigate back to the catalog URL */\n onClosePanel?: () => void\n}\n\nfunction getIconUrl(iconName: string): string {\n return `/api/icons/${iconName}`\n}\n\nfunction HighlightedText({\n text,\n searchQuery,\n}: {\n text: string\n searchQuery?: string\n}) {\n if (!searchQuery) {\n return <>{text}</>\n }\n\n const segments = highlightText(text, searchQuery)\n\n return (\n <>\n {segments.map((segment, index) =>\n segment.highlight ? (\n <mark\n key={index}\n className=\"bg-yellow-300 dark:bg-yellow-600/60 font-semibold text-gray-900 dark:text-gray-100\"\n >\n {segment.text}\n </mark>\n ) : (\n <React.Fragment key={index}>{segment.text}</React.Fragment>\n ),\n )}\n </>\n )\n}\n\nfunction AppIcon({ app, className }: { app: Resource; className?: string }) {\n const [imageError, setImageError] = React.useState(false)\n\n // Use iconName from backend if available\n if (app.iconName && !imageError) {\n return (\n <div className={cn('size-12 shrink-0', className)}>\n <img\n src={getIconUrl(app.iconName)}\n alt={`${app.abbreviation || app.displayName} icon`}\n className=\"size-12 rounded-lg object-contain\"\n onError={() => setImageError(true)}\n />\n </div>\n )\n }\n\n // Fallback icon\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded-lg bg-primary/10 text-primary size-12 shrink-0',\n className,\n )}\n >\n <AppWindow className=\"size-6\" />\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n // Check if app has screenshots\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nfunction TiersAndSubResourcesPanel({ app }: { app: Resource }) {\n const { resources } = useAppCatalogContext()\n const appSubResources = React.useMemo(\n () => getChildResources(resources, app.slug),\n [resources, app.slug],\n )\n\n return (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <div className=\"mt-6\">\n <TierVariantsSection tiers={app.tiers} />\n </div>\n )}\n {appSubResources.length > 0 && (\n <div className=\"mt-6\">\n <SubResourcesSection subResources={appSubResources} />\n </div>\n )}\n </>\n )\n}\n\nfunction AppDetails({\n app,\n onAppClick,\n onClosePanel,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n onClosePanel: () => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n\n // Enter: open screenshot gallery\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen],\n )\n\n // Esc: close the details panel (only when gallery is NOT open)\n useHotkeys(\n 'escape',\n () => {\n onClosePanel()\n },\n { enabled: !isGalleryOpen },\n [isGalleryOpen, onClosePanel],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n return (\n <>\n <div className=\"flex h-full flex-col p-6\">\n {/* Icon and Title */}\n <div className=\"border-b pb-6\">\n <div className=\"flex items-center gap-3\">\n <AppIcon app={app} className=\"size-16\" />\n <div className=\"-mx-3 flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 px-3\">\n <div className=\"text-2xl font-semibold min-w-0\">\n {app.abbreviation\n ? `${app.displayName} (${app.abbreviation})`\n : app.displayName}\n </div>\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n </div>\n {isAdmin && (\n <div className=\"mt-1 px-3\">\n <span className=\"text-xs text-muted-foreground mr-2\">\n Slug:\n </span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </div>\n )}\n <div className=\"mt-1 px-3\">\n {isAdmin ? (\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n renderView={(url) =>\n url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className=\"inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all\"\n >\n {url.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3.5 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n ) : app.appUrl ? (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className=\"inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all\"\n >\n {app.appUrl.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3.5 shrink-0 opacity-40 transition-opacity\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n </div>\n </div>\n </div>\n </div>\n\n {/* Deprecation/Discouraged Warning */}\n {app.deprecated &&\n (() => {\n const deprecationType = app.deprecated.type || 'deprecated'\n const isDiscouraged = deprecationType === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-6 p-4 border border-yellow-500/50 rounded-lg bg-yellow-50 dark:bg-yellow-950/20'\n : 'mt-6 p-4 border border-destructive/50 rounded-lg bg-destructive/10'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'text-sm font-semibold text-yellow-700 dark:text-yellow-500 mb-2'\n : 'text-sm font-semibold text-destructive mb-2'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground mb-3\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"text-sm font-medium text-primary hover:underline inline-flex items-center gap-1\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n\n {/* Description */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Description</h3>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </div>\n\n {/* Screenshots - Clickable preview */}\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Screenshots ({app.screenshotIds.length})\n </h3>\n <div\n className=\"cursor-pointer hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </div>\n </div>\n )}\n\n {/* Access Request Section */}\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n\n {/* Tier Variants and Sub-Resources */}\n <TiersAndSubResourcesPanel app={app} />\n\n {/* Links */}\n {app.links && app.links.length > 0 && (\n <div className=\"mt-4\">\n <h3 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n Links\n </h3>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || link.url.replace(/https?:\\/\\//g, '')}\n </a>\n ))}\n </div>\n </div>\n )}\n\n {/* Tags */}\n {app.tags && app.tags.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Tags</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.tags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Teams */}\n {app.teams && app.teams.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Teams</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Sources */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Sources</h3>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {val.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n <li key={index} className=\"flex items-center gap-2 text-xs\">\n <span className=\"text-muted-foreground shrink-0\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {url.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n\ninterface GroupedApps {\n groupName: string\n apps: Resource[]\n}\n\nfunction groupApps(\n apps: Resource[],\n groupingDef?: GroupingTagDefinition,\n hasSearch?: boolean,\n): GroupedApps[] {\n // When search is active, skip grouping and preserve relevance order\n if (hasSearch) {\n return [{ groupName: 'All Apps', apps: [...apps] }]\n }\n\n if (!groupingDef) {\n // No grouping definition - sort alphabetically\n const sortedApps = [...apps].sort((a, b) =>\n a.displayName.localeCompare(b.displayName),\n )\n return [{ groupName: 'All Apps', apps: sortedApps }]\n }\n\n const grouped = new Map<string, Resource[]>()\n const ungrouped: Resource[] = []\n\n for (const app of apps) {\n const matchingTag = app.tags?.find((tag) =>\n tag.startsWith(`${groupingDef.prefix}:`),\n )\n\n if (matchingTag) {\n const value = matchingTag.split(':')[1]\n if (value) {\n const tagValue = groupingDef.values.find((v) => v.value === value)\n const displayName = tagValue?.displayName || value\n\n if (!grouped.has(displayName)) {\n grouped.set(displayName, [])\n }\n grouped.get(displayName)!.push(app)\n } else {\n ungrouped.push(app)\n }\n } else {\n ungrouped.push(app)\n }\n }\n\n const result: GroupedApps[] = []\n for (const [groupName, appsInGroup] of grouped) {\n // Sort alphabetically within each group\n const sortedGroupApps = appsInGroup.sort((a, b) =>\n a.displayName.localeCompare(b.displayName),\n )\n result.push({ groupName, apps: sortedGroupApps })\n }\n\n if (ungrouped.length > 0) {\n // Sort alphabetically\n const sortedUngrouped = ungrouped.sort((a, b) =>\n a.displayName.localeCompare(b.displayName),\n )\n result.push({ groupName: 'Other', apps: sortedUngrouped })\n }\n\n // Sort groups by app count descending\n result.sort((a, b) => b.apps.length - a.apps.length)\n\n return result\n}\n\nexport function AppCatalogGrid({\n apps,\n selectedAppSlug,\n groupingDefinition,\n onAppClick,\n hasSearch = false,\n searchQuery,\n totalAppsCount,\n onClearFilters,\n onClosePanel,\n}: AppCatalogGridProps) {\n // Full, unfiltered resource set — the detail panel must resolve the open app\n // from this, not only from the filtered `apps`. Navigating to /app/<slug>\n // (e.g. a deprecated app's \"View replacement\" link, or any deep link) should\n // render that app like typing the URL in the browser, regardless of the\n // active search/filters (#12).\n const { resources: allResourcesForDetail } = useAppCatalogContext()\n const selectedApp = selectedAppSlug\n ? (apps.find((a) => a.slug === selectedAppSlug) ??\n allResourcesForDetail.find((a) => a.slug === selectedAppSlug))\n : null\n\n const groupedApps = groupApps(apps, groupingDefinition, hasSearch)\n\n // Flatten grouped apps to get display order for keyboard navigation\n const appsInDisplayOrder = React.useMemo(\n () => groupedApps.flatMap((group) => group.apps),\n [groupedApps],\n )\n\n // Use keyboard navigation hook with apps in display order\n const { rowRefs } = useKeyboardNavigation({\n apps: appsInDisplayOrder,\n selectedAppSlug,\n onAppClick,\n })\n\n // Build a map of parentSlug -> matched child resource displayName for search annotation\n const { resources: allResources2 } = useAppCatalogContext()\n const matchedSubResourceMap = React.useMemo(() => {\n const map = new Map<string, string>()\n if (!searchQuery?.trim() || allResources2.length === 0) return map\n const queryTerms = searchQuery\n .trim()\n .toLowerCase()\n .split(/\\s+/)\n .filter(Boolean)\n const allTermsMatch = (text: string): boolean =>\n queryTerms.every((term) => text.includes(term))\n\n for (const r of allResources2) {\n if (!r.parentSlug) continue\n if (map.has(r.parentSlug)) continue\n const nameMatch = allTermsMatch(r.displayName.toLowerCase())\n const aliasMatch = (r.aliases ?? []).some((a) =>\n allTermsMatch(a.toLowerCase()),\n )\n const descMatch = r.description\n ? allTermsMatch(r.description.toLowerCase())\n : false\n if (nameMatch || aliasMatch || descMatch) {\n map.set(r.parentSlug, r.displayName)\n }\n }\n return map\n }, [searchQuery, allResources2])\n\n // Define columns\n const columns = React.useMemo<ColumnDef<Resource>[]>(\n () => [\n {\n id: 'application',\n header: 'Application',\n cell: ({ row }) => (\n <div className=\"flex items-center gap-3\">\n <AppIcon app={row.original} className=\"size-6\" />\n <div className=\"flex flex-col\">\n <div className=\"flex items-center gap-2\">\n <span className=\"font-medium\">\n <HighlightedText\n text={\n row.original.abbreviation ||\n row.original.displayName ||\n 'Unnamed App'\n }\n searchQuery={searchQuery}\n />\n </span>\n {row.original.deprecated &&\n (() => {\n const deprecationType =\n row.original.deprecated.type || 'deprecated'\n return (\n <span className=\"text-[0.7rem] text-muted-foreground\">\n (\n {deprecationType === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n )\n </span>\n )\n })()}\n </div>\n {row.original.abbreviation && (\n <span className=\"text-xs text-muted-foreground\">\n <HighlightedText\n text={row.original.displayName}\n searchQuery={searchQuery}\n />\n </span>\n )}\n </div>\n </div>\n ),\n meta: {\n className: 'w-[300px]',\n },\n },\n {\n id: 'description',\n header: 'Description',\n cell: ({ row }) => (\n <div>\n <span className=\"text-sm text-muted-foreground line-clamp-2\">\n <HighlightedText\n text={row.original.description || '—'}\n searchQuery={searchQuery}\n />\n </span>\n {matchedSubResourceMap.get(row.original.slug) && (\n <div className=\"text-xs text-primary mt-0.5\">\n Matched sub-resource:{' '}\n {matchedSubResourceMap.get(row.original.slug)}\n </div>\n )}\n </div>\n ),\n },\n ],\n [searchQuery, matchedSubResourceMap],\n )\n\n // Create a single table instance with all apps\n const table = useReactTable({\n data: apps,\n columns,\n getCoreRowModel: getCoreRowModel(),\n getRowId: (row) => row.id,\n })\n\n // Panel visibility state - derive from selectedApp and explicit close\n const [hasUserClosed, setHasUserClosed] = useState(false)\n\n // Auto-open when app is selected, unless user explicitly closed\n const isPanelOpen = selectedApp !== null && !hasUserClosed\n\n // Reset close flag when selectedApp changes\n React.useEffect(() => {\n if (selectedApp) {\n setHasUserClosed(false)\n }\n }, [selectedApp])\n\n // Auto-scroll to selected app (only on initial load)\n const hasScrolledRef = React.useRef(false)\n React.useEffect(() => {\n // Only scroll once on initial load if there's a selection\n if (selectedAppSlug && !hasScrolledRef.current) {\n const rowElement = rowRefs.current.get(selectedAppSlug)\n if (rowElement) {\n rowElement.scrollIntoView({ behavior: 'smooth', block: 'center' })\n }\n hasScrolledRef.current = true\n }\n }, [selectedAppSlug, rowRefs])\n\n const handleAppClick = (app: Resource) => {\n onAppClick?.(app)\n }\n\n const handleClosePanel = () => {\n setHasUserClosed(true)\n onClosePanel?.()\n }\n\n return (\n <ResizablePanelGroup orientation=\"horizontal\" className=\"h-full w-full\">\n {/* Left Panel - Table */}\n <ResizablePanel\n defaultSize={isPanelOpen ? 60 : 100}\n minSize={30}\n className=\"overflow-hidden\"\n >\n <div className=\"h-full overflow-y-auto pr-2 pb-6 [scrollbar-gutter:stable]\">\n <Table>\n <TableHeader className=\"sticky top-0 border-b bg-background z-10\">\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id}>\n {headerGroup.headers.map((header) => (\n <TableHead\n key={header.id}\n className={cn(\n 'px-4 py-3 text-left font-medium text-sm',\n header.column.columnDef.meta?.className,\n )}\n >\n {header.isPlaceholder\n ? null\n : flexRender(\n header.column.columnDef.header,\n header.getContext(),\n )}\n </TableHead>\n ))}\n </TableRow>\n ))}\n </TableHeader>\n\n <TableBody>\n {groupedApps.map((group) => (\n <React.Fragment key={group.groupName}>\n {/* Group Header Row */}\n <TableRow className=\"bg-muted/50 hover:bg-muted/50\">\n <TableCell\n colSpan={columns.length}\n className=\"px-4 py-6 sticky top-[49px] bg-muted/90 backdrop-blur z-10\"\n >\n <div className=\"flex items-center justify-center\">\n <span className=\"font-bold text-lg tracking-widest uppercase leading-loose text-muted-foreground\">\n {group.groupName}\n </span>\n </div>\n </TableCell>\n </TableRow>\n\n {/* Group Apps */}\n {group.apps.map((app) => {\n const row = table\n .getRowModel()\n .rows.find((r) => r.id === app.id)\n if (!row) return null\n\n return (\n <TableRow\n key={row.id}\n ref={(el) => {\n if (el && row.original.slug) {\n rowRefs.current.set(row.original.slug, el)\n } else if (row.original.slug) {\n rowRefs.current.delete(row.original.slug)\n }\n }}\n onClick={() => handleAppClick(row.original)}\n className={cn(\n 'border-b cursor-pointer transition-colors',\n selectedApp?.id === row.original.id\n ? 'bg-blue-100 dark:bg-blue-950 hover:bg-blue-200 dark:hover:bg-blue-900'\n : 'hover:bg-muted/30',\n )}\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell\n key={cell.id}\n className={cn(\n 'px-4 py-4',\n cell.column.columnDef.meta?.className,\n )}\n >\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext(),\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })}\n </React.Fragment>\n ))}\n\n {/* Clear Filters Row */}\n {totalAppsCount &&\n totalAppsCount > apps.length &&\n onClearFilters && (\n <TableRow>\n <TableCell\n colSpan={columns.length}\n className=\"px-4 py-8 text-center\"\n >\n <Button\n variant=\"outline\"\n onClick={onClearFilters}\n className=\"gap-2\"\n >\n <X className=\"h-4 w-4\" />\n Clear filters to show all apps ({totalAppsCount})\n </Button>\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </div>\n </ResizablePanel>\n\n {/* Right Panel - Details (only render when panel is open) */}\n {isPanelOpen && (\n <>\n {/* Resizable Handle */}\n <ResizableHandle withHandle />\n\n <ResizablePanel\n defaultSize={40}\n minSize={25}\n className=\"overflow-hidden\"\n >\n <div className=\"h-full overflow-y-auto border-l bg-background pl-4\">\n {selectedApp ? (\n <div className=\"relative\">\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"absolute top-4 right-4 z-10 hover:bg-accent\"\n onClick={handleClosePanel}\n aria-label=\"Close details panel\"\n >\n <X className=\"h-5 w-5\" />\n </Button>\n <AppDetails\n app={selectedApp}\n onAppClick={onAppClick}\n onClosePanel={handleClosePanel}\n />\n </div>\n ) : null}\n </div>\n </ResizablePanel>\n </>\n )}\n </ResizablePanelGroup>\n )\n}\n"],"names":["React","_a"],"mappings":"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"}
1
+ {"version":3,"file":"AppCatalogGrid.js","sources":["../../../../../../src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx"],"sourcesContent":["import type {\n GroupingTagDefinition,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport {\n flexRender,\n getCoreRowModel,\n useReactTable,\n} from '@tanstack/react-table'\nimport { AppWindow, ExternalLink, Plus, Trash2, X } from 'lucide-react'\nimport React, { useState } from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { cn } from '~/lib/utils'\nimport type {} from '~/types/table'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n ResizableHandle,\n ResizablePanel,\n ResizablePanelGroup,\n} from '~/ui/resizable'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { useKeyboardNavigation } from '../hooks/useKeyboardNavigation'\nimport { highlightText } from '../../utils/searchApps'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\n\nexport interface AppCatalogGridProps {\n apps: Resource[]\n selectedAppSlug?: string\n groupingDefinition?: GroupingTagDefinition\n onAppClick?: (app: Resource) => void\n /** Whether search is active (affects group sorting) */\n hasSearch?: boolean\n /** Search query for highlighting matches */\n searchQuery?: string\n /** Total count of apps before filtering */\n totalAppsCount?: number\n /** Callback to clear all filters and search */\n onClearFilters?: () => void\n /** Called when the user closes the detail panel (Esc or X) — e.g. to navigate back to the catalog URL */\n onClosePanel?: () => void\n}\n\nfunction getIconUrl(iconName: string): string {\n return `/api/icons/${iconName}`\n}\n\nfunction HighlightedText({\n text,\n searchQuery,\n}: {\n text: string\n searchQuery?: string\n}) {\n if (!searchQuery) {\n return <>{text}</>\n }\n\n const segments = highlightText(text, searchQuery)\n\n return (\n <>\n {segments.map((segment, index) =>\n segment.highlight ? (\n <mark\n key={index}\n className=\"bg-yellow-300 dark:bg-yellow-600/60 font-semibold text-gray-900 dark:text-gray-100\"\n >\n {segment.text}\n </mark>\n ) : (\n <React.Fragment key={index}>{segment.text}</React.Fragment>\n ),\n )}\n </>\n )\n}\n\nfunction AppIcon({ app, className }: { app: Resource; className?: string }) {\n const [imageError, setImageError] = React.useState(false)\n\n // Use iconName from backend if available\n if (app.iconName && !imageError) {\n return (\n <div className={cn('size-12 shrink-0', className)}>\n <img\n src={getIconUrl(app.iconName)}\n alt={`${app.abbreviation || app.displayName} icon`}\n className=\"size-12 rounded-lg object-contain\"\n onError={() => setImageError(true)}\n />\n </div>\n )\n }\n\n // Fallback icon\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded-lg bg-primary/10 text-primary size-12 shrink-0',\n className,\n )}\n >\n <AppWindow className=\"size-6\" />\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n // Check if app has screenshots\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nfunction TiersAndSubResourcesPanel({ app }: { app: Resource }) {\n const { resources } = useAppCatalogContext()\n const appSubResources = React.useMemo(\n () => getChildResources(resources, app.slug),\n [resources, app.slug],\n )\n\n return (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <div className=\"mt-6\">\n <TierVariantsSection tiers={app.tiers} />\n </div>\n )}\n {appSubResources.length > 0 && (\n <div className=\"mt-6\">\n <SubResourcesSection subResources={appSubResources} />\n </div>\n )}\n </>\n )\n}\n\nfunction AppDetails({\n app,\n onAppClick,\n onClosePanel,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n onClosePanel: () => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n\n // Enter: open screenshot gallery\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen],\n )\n\n // Esc: close the details panel (only when gallery is NOT open)\n useHotkeys(\n 'escape',\n () => {\n onClosePanel()\n },\n { enabled: !isGalleryOpen },\n [isGalleryOpen, onClosePanel],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n return (\n <>\n <div className=\"flex h-full flex-col p-6\">\n {/* Icon and Title */}\n <div className=\"border-b pb-6\">\n <div className=\"flex items-center gap-3\">\n <AppIcon app={app} className=\"size-16\" />\n <div className=\"-mx-3 flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 px-3\">\n <div className=\"text-2xl font-semibold min-w-0\">\n {app.abbreviation\n ? `${app.displayName} (${app.abbreviation})`\n : app.displayName}\n </div>\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n </div>\n {isAdmin && (\n <div className=\"mt-1 px-3\">\n <span className=\"text-xs text-muted-foreground mr-2\">\n Slug:\n </span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </div>\n )}\n <div className=\"mt-1 px-3\">\n {isAdmin ? (\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n renderView={(url) =>\n url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className=\"inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all\"\n >\n {url.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3.5 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n ) : app.appUrl ? (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className=\"inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all\"\n >\n {app.appUrl.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3.5 shrink-0 opacity-40 transition-opacity\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n </div>\n </div>\n </div>\n </div>\n\n {/* Deprecation/Discouraged Warning */}\n {app.deprecated &&\n (() => {\n const deprecationType = app.deprecated.type || 'deprecated'\n const isDiscouraged = deprecationType === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-6 p-4 border border-yellow-500/50 rounded-lg bg-yellow-50 dark:bg-yellow-950/20'\n : 'mt-6 p-4 border border-destructive/50 rounded-lg bg-destructive/10'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'text-sm font-semibold text-yellow-700 dark:text-yellow-500 mb-2'\n : 'text-sm font-semibold text-destructive mb-2'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground mb-3\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"text-sm font-medium text-primary hover:underline inline-flex items-center gap-1\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n\n {/* Description */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Description</h3>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </div>\n\n {/* Screenshots - Clickable preview */}\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Screenshots ({app.screenshotIds.length})\n </h3>\n <div\n className=\"cursor-pointer hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </div>\n </div>\n )}\n\n {/* Access Request Section */}\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n\n {/* Tier Variants and Sub-Resources */}\n <TiersAndSubResourcesPanel app={app} />\n\n {/* Links */}\n {app.links && app.links.length > 0 && (\n <div className=\"mt-4\">\n <h3 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n Links\n </h3>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || link.url.replace(/https?:\\/\\//g, '')}\n </a>\n ))}\n </div>\n </div>\n )}\n\n {/* Tags */}\n {app.tags && app.tags.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Tags</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.tags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Teams */}\n {app.teams && app.teams.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Teams</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Sources */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Sources</h3>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {val.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n <li key={index} className=\"flex items-center gap-2 text-xs\">\n <span className=\"text-muted-foreground shrink-0\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {url.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n\n {/* Freshness: muted \"last checked\" line, rarely-needed metadata. The\n backend owns the semantic (freshness.isStale); we only render. */}\n {app.freshness?.lastCheckedAt && (\n <p className=\"mt-4 text-xs text-muted-foreground\">\n <span title={app.freshness.lastCheckedAt}>\n Last checked {formatRelativeTime(app.freshness.lastCheckedAt)}\n </span>\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\"> · may be out of date</span>\n )}\n </p>\n )}\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n\ninterface GroupedApps {\n groupName: string\n apps: Resource[]\n}\n\nfunction groupApps(\n apps: Resource[],\n groupingDef?: GroupingTagDefinition,\n hasSearch?: boolean,\n): GroupedApps[] {\n // When search is active, skip grouping and preserve relevance order\n if (hasSearch) {\n return [{ groupName: 'All Apps', apps: [...apps] }]\n }\n\n if (!groupingDef) {\n // No grouping definition - sort alphabetically\n const sortedApps = [...apps].sort((a, b) =>\n a.displayName.localeCompare(b.displayName),\n )\n return [{ groupName: 'All Apps', apps: sortedApps }]\n }\n\n const grouped = new Map<string, Resource[]>()\n const ungrouped: Resource[] = []\n\n for (const app of apps) {\n const matchingTag = app.tags?.find((tag) =>\n tag.startsWith(`${groupingDef.prefix}:`),\n )\n\n if (matchingTag) {\n const value = matchingTag.split(':')[1]\n if (value) {\n const tagValue = groupingDef.values.find((v) => v.value === value)\n const displayName = tagValue?.displayName || value\n\n if (!grouped.has(displayName)) {\n grouped.set(displayName, [])\n }\n grouped.get(displayName)!.push(app)\n } else {\n ungrouped.push(app)\n }\n } else {\n ungrouped.push(app)\n }\n }\n\n const result: GroupedApps[] = []\n for (const [groupName, appsInGroup] of grouped) {\n // Sort alphabetically within each group\n const sortedGroupApps = appsInGroup.sort((a, b) =>\n a.displayName.localeCompare(b.displayName),\n )\n result.push({ groupName, apps: sortedGroupApps })\n }\n\n if (ungrouped.length > 0) {\n // Sort alphabetically\n const sortedUngrouped = ungrouped.sort((a, b) =>\n a.displayName.localeCompare(b.displayName),\n )\n result.push({ groupName: 'Other', apps: sortedUngrouped })\n }\n\n // Sort groups by app count descending\n result.sort((a, b) => b.apps.length - a.apps.length)\n\n return result\n}\n\nexport function AppCatalogGrid({\n apps,\n selectedAppSlug,\n groupingDefinition,\n onAppClick,\n hasSearch = false,\n searchQuery,\n totalAppsCount,\n onClearFilters,\n onClosePanel,\n}: AppCatalogGridProps) {\n // Full, unfiltered resource set — the detail panel must resolve the open app\n // from this, not only from the filtered `apps`. Navigating to /app/<slug>\n // (e.g. a deprecated app's \"View replacement\" link, or any deep link) should\n // render that app like typing the URL in the browser, regardless of the\n // active search/filters (#12).\n const { resources: allResourcesForDetail } = useAppCatalogContext()\n const selectedApp = selectedAppSlug\n ? (apps.find((a) => a.slug === selectedAppSlug) ??\n allResourcesForDetail.find((a) => a.slug === selectedAppSlug))\n : null\n\n const groupedApps = groupApps(apps, groupingDefinition, hasSearch)\n\n // Flatten grouped apps to get display order for keyboard navigation\n const appsInDisplayOrder = React.useMemo(\n () => groupedApps.flatMap((group) => group.apps),\n [groupedApps],\n )\n\n // Use keyboard navigation hook with apps in display order\n const { rowRefs } = useKeyboardNavigation({\n apps: appsInDisplayOrder,\n selectedAppSlug,\n onAppClick,\n })\n\n // Build a map of parentSlug -> matched child resource displayName for search annotation\n const { resources: allResources2 } = useAppCatalogContext()\n const matchedSubResourceMap = React.useMemo(() => {\n const map = new Map<string, string>()\n if (!searchQuery?.trim() || allResources2.length === 0) return map\n const queryTerms = searchQuery\n .trim()\n .toLowerCase()\n .split(/\\s+/)\n .filter(Boolean)\n const allTermsMatch = (text: string): boolean =>\n queryTerms.every((term) => text.includes(term))\n\n for (const r of allResources2) {\n if (!r.parentSlug) continue\n if (map.has(r.parentSlug)) continue\n const nameMatch = allTermsMatch(r.displayName.toLowerCase())\n const aliasMatch = (r.aliases ?? []).some((a) =>\n allTermsMatch(a.toLowerCase()),\n )\n const descMatch = r.description\n ? allTermsMatch(r.description.toLowerCase())\n : false\n if (nameMatch || aliasMatch || descMatch) {\n map.set(r.parentSlug, r.displayName)\n }\n }\n return map\n }, [searchQuery, allResources2])\n\n // Define columns\n const columns = React.useMemo<ColumnDef<Resource>[]>(\n () => [\n {\n id: 'application',\n header: 'Application',\n cell: ({ row }) => (\n <div className=\"flex items-center gap-3\">\n <AppIcon app={row.original} className=\"size-6\" />\n <div className=\"flex flex-col\">\n <div className=\"flex items-center gap-2\">\n <span className=\"font-medium\">\n <HighlightedText\n text={\n row.original.abbreviation ||\n row.original.displayName ||\n 'Unnamed App'\n }\n searchQuery={searchQuery}\n />\n </span>\n {row.original.deprecated &&\n (() => {\n const deprecationType =\n row.original.deprecated.type || 'deprecated'\n return (\n <span className=\"text-[0.7rem] text-muted-foreground\">\n (\n {deprecationType === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n )\n </span>\n )\n })()}\n </div>\n {row.original.abbreviation && (\n <span className=\"text-xs text-muted-foreground\">\n <HighlightedText\n text={row.original.displayName}\n searchQuery={searchQuery}\n />\n </span>\n )}\n </div>\n </div>\n ),\n meta: {\n className: 'w-[300px]',\n },\n },\n {\n id: 'description',\n header: 'Description',\n cell: ({ row }) => (\n <div>\n <span className=\"text-sm text-muted-foreground line-clamp-2\">\n <HighlightedText\n text={row.original.description || '—'}\n searchQuery={searchQuery}\n />\n </span>\n {matchedSubResourceMap.get(row.original.slug) && (\n <div className=\"text-xs text-primary mt-0.5\">\n Matched sub-resource:{' '}\n {matchedSubResourceMap.get(row.original.slug)}\n </div>\n )}\n </div>\n ),\n },\n ],\n [searchQuery, matchedSubResourceMap],\n )\n\n // Create a single table instance with all apps\n const table = useReactTable({\n data: apps,\n columns,\n getCoreRowModel: getCoreRowModel(),\n getRowId: (row) => row.id,\n })\n\n // Panel visibility state - derive from selectedApp and explicit close\n const [hasUserClosed, setHasUserClosed] = useState(false)\n\n // Auto-open when app is selected, unless user explicitly closed\n const isPanelOpen = selectedApp !== null && !hasUserClosed\n\n // Reset close flag when selectedApp changes\n React.useEffect(() => {\n if (selectedApp) {\n setHasUserClosed(false)\n }\n }, [selectedApp])\n\n // Auto-scroll to selected app (only on initial load)\n const hasScrolledRef = React.useRef(false)\n React.useEffect(() => {\n // Only scroll once on initial load if there's a selection\n if (selectedAppSlug && !hasScrolledRef.current) {\n const rowElement = rowRefs.current.get(selectedAppSlug)\n if (rowElement) {\n rowElement.scrollIntoView({ behavior: 'smooth', block: 'center' })\n }\n hasScrolledRef.current = true\n }\n }, [selectedAppSlug, rowRefs])\n\n const handleAppClick = (app: Resource) => {\n onAppClick?.(app)\n }\n\n const handleClosePanel = () => {\n setHasUserClosed(true)\n onClosePanel?.()\n }\n\n return (\n <ResizablePanelGroup orientation=\"horizontal\" className=\"h-full w-full\">\n {/* Left Panel - Table */}\n <ResizablePanel\n defaultSize={isPanelOpen ? 60 : 100}\n minSize={30}\n className=\"overflow-hidden\"\n >\n <div className=\"h-full overflow-y-auto pr-2 pb-6 [scrollbar-gutter:stable]\">\n <Table>\n <TableHeader className=\"sticky top-0 border-b bg-background z-10\">\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id}>\n {headerGroup.headers.map((header) => (\n <TableHead\n key={header.id}\n className={cn(\n 'px-4 py-3 text-left font-medium text-sm',\n header.column.columnDef.meta?.className,\n )}\n >\n {header.isPlaceholder\n ? null\n : flexRender(\n header.column.columnDef.header,\n header.getContext(),\n )}\n </TableHead>\n ))}\n </TableRow>\n ))}\n </TableHeader>\n\n <TableBody>\n {groupedApps.map((group) => (\n <React.Fragment key={group.groupName}>\n {/* Group Header Row */}\n <TableRow className=\"bg-muted/50 hover:bg-muted/50\">\n <TableCell\n colSpan={columns.length}\n className=\"px-4 py-6 sticky top-[49px] bg-muted/90 backdrop-blur z-10\"\n >\n <div className=\"flex items-center justify-center\">\n <span className=\"font-bold text-lg tracking-widest uppercase leading-loose text-muted-foreground\">\n {group.groupName}\n </span>\n </div>\n </TableCell>\n </TableRow>\n\n {/* Group Apps */}\n {group.apps.map((app) => {\n const row = table\n .getRowModel()\n .rows.find((r) => r.id === app.id)\n if (!row) return null\n\n return (\n <TableRow\n key={row.id}\n ref={(el) => {\n if (el && row.original.slug) {\n rowRefs.current.set(row.original.slug, el)\n } else if (row.original.slug) {\n rowRefs.current.delete(row.original.slug)\n }\n }}\n onClick={() => handleAppClick(row.original)}\n className={cn(\n 'border-b cursor-pointer transition-colors',\n selectedApp?.id === row.original.id\n ? 'bg-blue-100 dark:bg-blue-950 hover:bg-blue-200 dark:hover:bg-blue-900'\n : 'hover:bg-muted/30',\n )}\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell\n key={cell.id}\n className={cn(\n 'px-4 py-4',\n cell.column.columnDef.meta?.className,\n )}\n >\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext(),\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })}\n </React.Fragment>\n ))}\n\n {/* Clear Filters Row */}\n {totalAppsCount &&\n totalAppsCount > apps.length &&\n onClearFilters && (\n <TableRow>\n <TableCell\n colSpan={columns.length}\n className=\"px-4 py-8 text-center\"\n >\n <Button\n variant=\"outline\"\n onClick={onClearFilters}\n className=\"gap-2\"\n >\n <X className=\"h-4 w-4\" />\n Clear filters to show all apps ({totalAppsCount})\n </Button>\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </div>\n </ResizablePanel>\n\n {/* Right Panel - Details (only render when panel is open) */}\n {isPanelOpen && (\n <>\n {/* Resizable Handle */}\n <ResizableHandle withHandle />\n\n <ResizablePanel\n defaultSize={40}\n minSize={25}\n className=\"overflow-hidden\"\n >\n <div className=\"h-full overflow-y-auto border-l bg-background pl-4\">\n {selectedApp ? (\n <div className=\"relative\">\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className=\"absolute top-4 right-4 z-10 hover:bg-accent\"\n onClick={handleClosePanel}\n aria-label=\"Close details panel\"\n >\n <X className=\"h-5 w-5\" />\n </Button>\n <AppDetails\n app={selectedApp}\n onAppClick={onAppClick}\n onClosePanel={handleClosePanel}\n />\n </div>\n ) : null}\n </div>\n </ResizablePanel>\n </>\n )}\n </ResizablePanelGroup>\n )\n}\n"],"names":["React","_a"],"mappings":"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"}
@@ -101,6 +101,23 @@ function AppCatalogPage({
101
101
  });
102
102
  }
103
103
  }, [filteredApps, selectedAppSlug, navigate, searchValue]);
104
+ useEffect(() => {
105
+ if (!selectedAppSlug || resources.length === 0) return;
106
+ const canonical = resources.some((r) => r.slug === selectedAppSlug);
107
+ if (canonical) return;
108
+ const aliased = resources.find((r) => {
109
+ var _a;
110
+ return (_a = r.aliases) == null ? void 0 : _a.includes(selectedAppSlug);
111
+ });
112
+ if (aliased) {
113
+ void navigate({
114
+ to: "/app/$slug",
115
+ params: { slug: aliased.slug },
116
+ search: (prev) => prev,
117
+ replace: true
118
+ });
119
+ }
120
+ }, [selectedAppSlug, resources, navigate]);
104
121
  const handleAppClick = (app) => {
105
122
  void navigate({
106
123
  to: "/app/$slug",
@@ -1 +1 @@
1
- {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate } from '@tanstack/react-router'\nimport { X } from 'lucide-react'\nimport { useDeferredValue, useEffect, useMemo, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Empty,\n EmptyContent,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from '~/ui/empty'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { useAppCounts } from '../../hooks/useAppCounts'\nimport { searchResources } from '../../utils/searchApps'\nimport { OnboardingCard } from '../components/OnboardingCard'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { FilterBar } from '../filters/FilterBar'\nimport { AppCatalogGrid } from '../grid/AppCatalogGrid'\n\nexport function AppCatalogPage({\n selectedSlug,\n}: { selectedSlug?: string } = {}) {\n const { resources, isLoadingApps, tagsDefinitions } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const { getTopApps } = useAppClickHistory()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (URL-synced in AppCatalogFiltersContext)\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Defer the search value used for filtering to avoid blocking the input\n const deferredSearchValue = useDeferredValue(searchValue)\n\n // State for top apps (loaded async)\n const [topAppSlugs, setTopAppSlugs] = useState<string[]>([])\n\n // Load top apps on mount to calculate recent count\n useEffect(() => {\n void getTopApps(10).then(setTopAppSlugs)\n }, [getTopApps])\n\n // Get root resources for filtering (children handled internally by searchResources)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n const { filteredApps, didDeprecatedFallback } = useMemo(() => {\n const childResources = resources.filter((r) => r.parentSlug)\n\n // Apply recent mode / tag filters / search over a base set of root apps.\n const runPipeline = (base: typeof rootResources) => {\n let result = base\n\n // Apply recent mode or tag filters\n if (filterState.recentMode) {\n // Filter to top 10 most clicked apps\n result = result.filter((app) => topAppSlugs.includes(app.slug))\n } else if (Object.keys(filterState.tagFilters).length > 0) {\n // Apply tag filters (AND condition)\n result = result.filter((app) => {\n return Object.entries(filterState.tagFilters).every(\n ([prefix, value]) => {\n const fullTag = `${prefix}:${value}`\n return app.tags?.some(\n (tag) => tag.toLowerCase() === fullTag.toLowerCase(),\n )\n },\n )\n })\n }\n\n // Apply search (using deferred value). Pass all resources so children\n // contribute to parent scoring.\n return searchResources(\n [...result, ...childResources],\n deferredSearchValue,\n )\n }\n\n // Matches among non-deprecated (\"active\") apps only.\n const activeMatches = runPipeline(\n rootResources.filter((app) => !app.deprecated),\n )\n\n // #11: derive the fallback purely from the data (not the toggle), so it\n // stays stable after the toggle auto-enables below — otherwise the notice\n // would flicker off as soon as showDeprecated flips to true.\n // Fallback applies when there's a query, no active matches, but deprecated\n // apps DO match.\n const hasQuery = deferredSearchValue.trim() !== ''\n if (hasQuery && activeMatches.length === 0) {\n const allMatches = runPipeline(rootResources)\n if (allMatches.length > 0) {\n return { filteredApps: allMatches, didDeprecatedFallback: true }\n }\n }\n\n // Normal display: include deprecated only when the toggle is on.\n const result = filterState.showDeprecated\n ? runPipeline(rootResources)\n : activeMatches\n return { filteredApps: result, didDeprecatedFallback: false }\n }, [\n rootResources,\n resources,\n deferredSearchValue,\n filterState.recentMode,\n filterState.tagFilters,\n filterState.showDeprecated,\n topAppSlugs,\n ])\n\n // #11: when the search fell back to deprecated results, enable the\n // \"Show Deprecated Apps\" toggle so the state is visible and consistent.\n // Runs as an effect (not in render) to avoid a side effect during useMemo.\n useEffect(() => {\n if (didDeprecatedFallback && !filterState.showDeprecated) {\n actions.setShowDeprecated(true)\n }\n }, [didDeprecatedFallback, filterState.showDeprecated, actions])\n\n // Calculate counts for FilterBar\n const { allCount, recentCount, deprecatedCount } = useAppCounts({\n apps: rootResources,\n topAppSlugs,\n searchValue: deferredSearchValue,\n })\n\n // Auto-open details when only 1 result. Carry the *current* search value into\n // the URL (`q`) as part of this navigation so the search input and focus\n // survive the route change (#10). We must inject `searchValue` explicitly\n // rather than rely on `(prev) => prev`: this child effect runs before the\n // provider's async state→URL sync effect, so at nav time the URL does not yet\n // hold `q` and the query would otherwise be lost on remount.\n useEffect(() => {\n if (filteredApps.length === 1 && filteredApps[0] && !selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: filteredApps[0].slug },\n search: (prev) => ({\n ...prev,\n q: searchValue === '' ? undefined : searchValue,\n }),\n replace: true,\n })\n }\n }, [filteredApps, selectedAppSlug, navigate, searchValue])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleClearFilters = () => {\n setSearchValue('')\n actions.clearAllFilters()\n void navigate({ to: '/' })\n }\n\n // Calculate total apps count (respecting showDeprecated setting)\n const totalAppsCount = useMemo(() => {\n let count = rootResources.length\n if (!filterState.showDeprecated) {\n count = rootResources.filter((app) => !app.deprecated).length\n }\n return count\n }, [rootResources, filterState.showDeprecated])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // Use first tag definition for grouping\n const groupingDefinition = tagsDefinitions[0]\n\n return (\n <div className=\"flex flex-col flex-1 min-h-0\">\n <div className=\"shrink-0\">\n <OnboardingCard />\n </div>\n\n <div className=\"shrink-0\">\n <FilterBar\n totalCount={allCount}\n recentCount={recentCount}\n deprecatedCount={deprecatedCount}\n apps={rootResources}\n />\n </div>\n\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"shrink-0 px-1 pb-2 text-sm text-muted-foreground\"\n >\n {`No active apps${\n searchValue ? ` for \"${searchValue}\"` : ''\n } — showing deprecated matches.`}\n </div>\n )}\n\n <div className=\"flex-1 min-h-0\">\n {filteredApps.length === 0 && !selectedAppSlug ? (\n <Empty>\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <X className=\"h-6 w-6\" />\n </EmptyMedia>\n <EmptyTitle>\n No apps found{searchValue && ` for \"${searchValue}\"`}\n </EmptyTitle>\n <EmptyDescription>\n Try adjusting your search or filters\n </EmptyDescription>\n </EmptyHeader>\n <EmptyContent>\n {searchValue && (\n <Button\n variant=\"outline\"\n onClick={() => {\n setSearchValue('')\n void navigate({ to: '/' })\n }}\n className=\"gap-2\"\n >\n <X className=\"h-4 w-4\" />\n Clear search\n </Button>\n )}\n </EmptyContent>\n </Empty>\n ) : (\n <AppCatalogGrid\n apps={filteredApps}\n selectedAppSlug={selectedAppSlug}\n groupingDefinition={groupingDefinition}\n onAppClick={handleAppClick}\n onClosePanel={() => void navigate({ to: '/' })}\n hasSearch={!!deferredSearchValue}\n searchQuery={deferredSearchValue}\n totalAppsCount={totalAppsCount}\n onClearFilters={handleClearFilters}\n />\n )}\n </div>\n </div>\n )\n}\n"],"names":["result"],"mappings":"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"}
1
+ {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate } from '@tanstack/react-router'\nimport { X } from 'lucide-react'\nimport { useDeferredValue, useEffect, useMemo, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Empty,\n EmptyContent,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from '~/ui/empty'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { useAppCounts } from '../../hooks/useAppCounts'\nimport { searchResources } from '../../utils/searchApps'\nimport { OnboardingCard } from '../components/OnboardingCard'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { FilterBar } from '../filters/FilterBar'\nimport { AppCatalogGrid } from '../grid/AppCatalogGrid'\n\nexport function AppCatalogPage({\n selectedSlug,\n}: { selectedSlug?: string } = {}) {\n const { resources, isLoadingApps, tagsDefinitions } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const { getTopApps } = useAppClickHistory()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (URL-synced in AppCatalogFiltersContext)\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Defer the search value used for filtering to avoid blocking the input\n const deferredSearchValue = useDeferredValue(searchValue)\n\n // State for top apps (loaded async)\n const [topAppSlugs, setTopAppSlugs] = useState<string[]>([])\n\n // Load top apps on mount to calculate recent count\n useEffect(() => {\n void getTopApps(10).then(setTopAppSlugs)\n }, [getTopApps])\n\n // Get root resources for filtering (children handled internally by searchResources)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n const { filteredApps, didDeprecatedFallback } = useMemo(() => {\n const childResources = resources.filter((r) => r.parentSlug)\n\n // Apply recent mode / tag filters / search over a base set of root apps.\n const runPipeline = (base: typeof rootResources) => {\n let result = base\n\n // Apply recent mode or tag filters\n if (filterState.recentMode) {\n // Filter to top 10 most clicked apps\n result = result.filter((app) => topAppSlugs.includes(app.slug))\n } else if (Object.keys(filterState.tagFilters).length > 0) {\n // Apply tag filters (AND condition)\n result = result.filter((app) => {\n return Object.entries(filterState.tagFilters).every(\n ([prefix, value]) => {\n const fullTag = `${prefix}:${value}`\n return app.tags?.some(\n (tag) => tag.toLowerCase() === fullTag.toLowerCase(),\n )\n },\n )\n })\n }\n\n // Apply search (using deferred value). Pass all resources so children\n // contribute to parent scoring.\n return searchResources(\n [...result, ...childResources],\n deferredSearchValue,\n )\n }\n\n // Matches among non-deprecated (\"active\") apps only.\n const activeMatches = runPipeline(\n rootResources.filter((app) => !app.deprecated),\n )\n\n // #11: derive the fallback purely from the data (not the toggle), so it\n // stays stable after the toggle auto-enables below — otherwise the notice\n // would flicker off as soon as showDeprecated flips to true.\n // Fallback applies when there's a query, no active matches, but deprecated\n // apps DO match.\n const hasQuery = deferredSearchValue.trim() !== ''\n if (hasQuery && activeMatches.length === 0) {\n const allMatches = runPipeline(rootResources)\n if (allMatches.length > 0) {\n return { filteredApps: allMatches, didDeprecatedFallback: true }\n }\n }\n\n // Normal display: include deprecated only when the toggle is on.\n const result = filterState.showDeprecated\n ? runPipeline(rootResources)\n : activeMatches\n return { filteredApps: result, didDeprecatedFallback: false }\n }, [\n rootResources,\n resources,\n deferredSearchValue,\n filterState.recentMode,\n filterState.tagFilters,\n filterState.showDeprecated,\n topAppSlugs,\n ])\n\n // #11: when the search fell back to deprecated results, enable the\n // \"Show Deprecated Apps\" toggle so the state is visible and consistent.\n // Runs as an effect (not in render) to avoid a side effect during useMemo.\n useEffect(() => {\n if (didDeprecatedFallback && !filterState.showDeprecated) {\n actions.setShowDeprecated(true)\n }\n }, [didDeprecatedFallback, filterState.showDeprecated, actions])\n\n // Calculate counts for FilterBar\n const { allCount, recentCount, deprecatedCount } = useAppCounts({\n apps: rootResources,\n topAppSlugs,\n searchValue: deferredSearchValue,\n })\n\n // Auto-open details when only 1 result. Carry the *current* search value into\n // the URL (`q`) as part of this navigation so the search input and focus\n // survive the route change (#10). We must inject `searchValue` explicitly\n // rather than rely on `(prev) => prev`: this child effect runs before the\n // provider's async state→URL sync effect, so at nav time the URL does not yet\n // hold `q` and the query would otherwise be lost on remount.\n useEffect(() => {\n if (filteredApps.length === 1 && filteredApps[0] && !selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: filteredApps[0].slug },\n search: (prev) => ({\n ...prev,\n q: searchValue === '' ? undefined : searchValue,\n }),\n replace: true,\n })\n }\n }, [filteredApps, selectedAppSlug, navigate, searchValue])\n\n // #22: alias → canonical redirect. When an app is renamed its old slug is\n // kept in `aliases[]`. If the URL slug matches no canonical slug but does\n // match some app's alias, redirect (replace) to that app's canonical slug so\n // old bookmarks resolve instead of showing a blank catalog. Guard on a real\n // canonical miss so we never fight the normal detail-open path. Note: this is\n // a client-side SPA redirect (replace), not an HTTP 301 — see ig-umbrella#22.\n useEffect(() => {\n if (!selectedAppSlug || resources.length === 0) return\n const canonical = resources.some((r) => r.slug === selectedAppSlug)\n if (canonical) return\n const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))\n if (aliased) {\n void navigate({\n to: '/app/$slug',\n params: { slug: aliased.slug },\n search: (prev) => prev,\n replace: true,\n })\n }\n }, [selectedAppSlug, resources, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleClearFilters = () => {\n setSearchValue('')\n actions.clearAllFilters()\n void navigate({ to: '/' })\n }\n\n // Calculate total apps count (respecting showDeprecated setting)\n const totalAppsCount = useMemo(() => {\n let count = rootResources.length\n if (!filterState.showDeprecated) {\n count = rootResources.filter((app) => !app.deprecated).length\n }\n return count\n }, [rootResources, filterState.showDeprecated])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // Use first tag definition for grouping\n const groupingDefinition = tagsDefinitions[0]\n\n return (\n <div className=\"flex flex-col flex-1 min-h-0\">\n <div className=\"shrink-0\">\n <OnboardingCard />\n </div>\n\n <div className=\"shrink-0\">\n <FilterBar\n totalCount={allCount}\n recentCount={recentCount}\n deprecatedCount={deprecatedCount}\n apps={rootResources}\n />\n </div>\n\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"shrink-0 px-1 pb-2 text-sm text-muted-foreground\"\n >\n {`No active apps${\n searchValue ? ` for \"${searchValue}\"` : ''\n } — showing deprecated matches.`}\n </div>\n )}\n\n <div className=\"flex-1 min-h-0\">\n {filteredApps.length === 0 && !selectedAppSlug ? (\n <Empty>\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <X className=\"h-6 w-6\" />\n </EmptyMedia>\n <EmptyTitle>\n No apps found{searchValue && ` for \"${searchValue}\"`}\n </EmptyTitle>\n <EmptyDescription>\n Try adjusting your search or filters\n </EmptyDescription>\n </EmptyHeader>\n <EmptyContent>\n {searchValue && (\n <Button\n variant=\"outline\"\n onClick={() => {\n setSearchValue('')\n void navigate({ to: '/' })\n }}\n className=\"gap-2\"\n >\n <X className=\"h-4 w-4\" />\n Clear search\n </Button>\n )}\n </EmptyContent>\n </Empty>\n ) : (\n <AppCatalogGrid\n apps={filteredApps}\n selectedAppSlug={selectedAppSlug}\n groupingDefinition={groupingDefinition}\n onAppClick={handleAppClick}\n onClosePanel={() => void navigate({ to: '/' })}\n hasSearch={!!deferredSearchValue}\n searchQuery={deferredSearchValue}\n totalAppsCount={totalAppsCount}\n onClearFilters={handleClearFilters}\n />\n )}\n </div>\n </div>\n )\n}\n"],"names":["result"],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export declare function formatRelativeTime(iso: string, now?: number): string;
@@ -0,0 +1,26 @@
1
+ const DIVISIONS = [
2
+ { amount: 60, unit: "second" },
3
+ { amount: 60, unit: "minute" },
4
+ { amount: 24, unit: "hour" },
5
+ { amount: 7, unit: "day" },
6
+ { amount: 4.34524, unit: "week" },
7
+ { amount: 12, unit: "month" },
8
+ { amount: Number.POSITIVE_INFINITY, unit: "year" }
9
+ ];
10
+ function formatRelativeTime(iso, now = Date.now()) {
11
+ const then = Date.parse(iso);
12
+ if (Number.isNaN(then)) return "";
13
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
14
+ let duration = (then - now) / 1e3;
15
+ for (const division of DIVISIONS) {
16
+ if (Math.abs(duration) < division.amount) {
17
+ return rtf.format(Math.round(duration), division.unit);
18
+ }
19
+ duration /= division.amount;
20
+ }
21
+ return rtf.format(Math.round(duration), "year");
22
+ }
23
+ export {
24
+ formatRelativeTime
25
+ };
26
+ //# sourceMappingURL=formatRelativeTime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formatRelativeTime.js","sources":["../../../../../src/modules/appCatalog/utils/formatRelativeTime.ts"],"sourcesContent":["/**\n * Format an ISO timestamp as a short, human relative phrase (\"2 days ago\").\n * Used for the muted \"Last checked …\" line on the app detail view.\n *\n * Kept tiny and dependency-free (Intl.RelativeTimeFormat) — the frontend has no\n * date library and this is the only place that needs relative time.\n */\nconst DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [\n { amount: 60, unit: 'second' },\n { amount: 60, unit: 'minute' },\n { amount: 24, unit: 'hour' },\n { amount: 7, unit: 'day' },\n { amount: 4.34524, unit: 'week' },\n { amount: 12, unit: 'month' },\n { amount: Number.POSITIVE_INFINITY, unit: 'year' },\n]\n\nexport function formatRelativeTime(\n iso: string,\n now: number = Date.now(),\n): string {\n const then = Date.parse(iso)\n if (Number.isNaN(then)) return ''\n const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })\n let duration = (then - now) / 1000 // seconds, negative for the past\n for (const division of DIVISIONS) {\n if (Math.abs(duration) < division.amount) {\n return rtf.format(Math.round(duration), division.unit)\n }\n duration /= division.amount\n }\n return rtf.format(Math.round(duration), 'year')\n}\n"],"names":[],"mappings":"AAOA,MAAM,YAAqE;AAAA,EACzE,EAAE,QAAQ,IAAI,MAAM,SAAA;AAAA,EACpB,EAAE,QAAQ,IAAI,MAAM,SAAA;AAAA,EACpB,EAAE,QAAQ,IAAI,MAAM,OAAA;AAAA,EACpB,EAAE,QAAQ,GAAG,MAAM,MAAA;AAAA,EACnB,EAAE,QAAQ,SAAS,MAAM,OAAA;AAAA,EACzB,EAAE,QAAQ,IAAI,MAAM,QAAA;AAAA,EACpB,EAAE,QAAQ,OAAO,mBAAmB,MAAM,OAAA;AAC5C;AAEO,SAAS,mBACd,KACA,MAAc,KAAK,OACX;AACR,QAAM,OAAO,KAAK,MAAM,GAAG;AAC3B,MAAI,OAAO,MAAM,IAAI,EAAG,QAAO;AAC/B,QAAM,MAAM,IAAI,KAAK,mBAAmB,MAAM,EAAE,SAAS,QAAQ;AACjE,MAAI,YAAY,OAAO,OAAO;AAC9B,aAAW,YAAY,WAAW;AAChC,QAAI,KAAK,IAAI,QAAQ,IAAI,SAAS,QAAQ;AACxC,aAAO,IAAI,OAAO,KAAK,MAAM,QAAQ,GAAG,SAAS,IAAI;AAAA,IACvD;AACA,gBAAY,SAAS;AAAA,EACvB;AACA,SAAO,IAAI,OAAO,KAAK,MAAM,QAAQ,GAAG,MAAM;AAChD;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -134,8 +134,8 @@
134
134
  "vite-plugin-static-copy": "^3.1.4",
135
135
  "vite-plugin-svgr": "^4.2.0",
136
136
  "vitest": "^4.1.2",
137
- "@igstack/app-catalog-backend-core": "0.8.1",
138
- "@igstack/app-catalog-shared-core": "0.8.1"
137
+ "@igstack/app-catalog-backend-core": "0.9.0",
138
+ "@igstack/app-catalog-shared-core": "0.9.0"
139
139
  },
140
140
  "peerDependencies": {
141
141
  "react": "19.1.2",
@@ -144,7 +144,7 @@
144
144
  "vite": "^6.3.5",
145
145
  "vite-plugin-svgr": "^4.2.0"
146
146
  },
147
- "gitHead": "16c12c4d457cd8ca88e30ffba17e8b6c23e4d527",
147
+ "gitHead": "a27e7c29dd93494100f891f47b2bc4cea4091125",
148
148
  "scripts": {
149
149
  "build": "vite build",
150
150
  "build:lenient": "vite build --mode lenient",
@@ -79,6 +79,29 @@ describe('App deep-link routing', () => {
79
79
  expect(ui.catalog.getSearchInput().value).toBe(q)
80
80
  })
81
81
 
82
+ it('redirects /app/<alias> to the canonical /app/<slug> (replace, no back entry)', async () => {
83
+ // #22: when an app is renamed, its old slug is preserved in aliases[].
84
+ // Hitting the old URL should land the user on the canonical app, not a
85
+ // blank catalog, and must use replace (so Back doesn't bounce to the alias).
86
+ const { ui, router } = await given(
87
+ magazine.full(({ backendCfg }) => {
88
+ backendCfg.withApp({
89
+ slug: 'cluster-operations',
90
+ displayName: 'Cluster Operations',
91
+ aliases: ['prod-ops-tool'],
92
+ })
93
+ }),
94
+ { initialRoute: '/app/prod-ops-tool' },
95
+ )
96
+
97
+ await waitFor(() => {
98
+ expect(router.state.location.pathname).toBe('/app/cluster-operations')
99
+ })
100
+ expect(ui.catalog.isDetailPanelOpen()).toBe(true)
101
+ // replace, not push: the alias URL must not be a Back target
102
+ expect(router.history.canGoBack()).toBe(false)
103
+ })
104
+
82
105
  it('populates the search input from ?q= on the app detail route (#10 read path)', async () => {
83
106
  // The detail route must NOT strip `q` from the URL: landing on /app/<slug>
84
107
  // with a query must repopulate the search input. This is what was actually
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { screen, waitFor } from '@testing-library/react'
3
+ import '@testing-library/jest-dom/vitest'
4
+
5
+ import { given } from './harness/given'
6
+ import { magazine } from './mock-backend/magazines'
7
+
8
+ // The backend owns the freshness semantic and serves { lastCheckedAt, isStale }.
9
+ // These tests only assert the frontend renders that semantic — no date math here.
10
+ const DAY = 24 * 60 * 60 * 1000
11
+ const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString()
12
+
13
+ describe('App freshness line', () => {
14
+ it('shows a muted "Last checked" line when the app has freshness', async () => {
15
+ const { ui } = await given(
16
+ magazine.full(({ backendCfg }) => {
17
+ backendCfg.withApp({
18
+ slug: 'fresh-app',
19
+ displayName: 'Fresh App',
20
+ freshness: { lastCheckedAt: iso(2 * DAY), isStale: false },
21
+ })
22
+ }),
23
+ { initialRoute: '/app/fresh-app' },
24
+ )
25
+
26
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
27
+ expect(screen.getByText(/Last checked/i)).toBeInTheDocument()
28
+ // fresh → no stale note
29
+ expect(screen.queryByText(/may be out of date/i)).toBeNull()
30
+ })
31
+
32
+ it('appends "may be out of date" when the backend marks it stale', async () => {
33
+ const { ui } = await given(
34
+ magazine.full(({ backendCfg }) => {
35
+ backendCfg.withApp({
36
+ slug: 'stale-app',
37
+ displayName: 'Stale App',
38
+ freshness: { lastCheckedAt: iso(30 * DAY), isStale: true },
39
+ })
40
+ }),
41
+ { initialRoute: '/app/stale-app' },
42
+ )
43
+
44
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
45
+ expect(screen.getByText(/may be out of date/i)).toBeInTheDocument()
46
+ })
47
+
48
+ it('shows no freshness line when the app was never scanned', async () => {
49
+ const { ui } = await given(
50
+ magazine.full(({ backendCfg }) => {
51
+ backendCfg.withApp({
52
+ slug: 'unscanned-app',
53
+ displayName: 'Unscanned App',
54
+ // no freshness field
55
+ })
56
+ }),
57
+ { initialRoute: '/app/unscanned-app' },
58
+ )
59
+
60
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
61
+ expect(screen.queryByText(/Last checked/i)).toBeNull()
62
+ })
63
+ })
@@ -38,6 +38,7 @@ import { useAppCatalogContext } from '../../context/AppCatalogContext'
38
38
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
39
39
  import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation'
40
40
  import { highlightText } from '../../utils/searchApps'
41
+ import { formatRelativeTime } from '../../utils/formatRelativeTime'
41
42
  import { TierVariantsSection } from '../components/TierVariantsSection'
42
43
  import { SubResourcesSection } from '../components/SubResourcesSection'
43
44
  import { getChildResources } from '../../utils/resolveHelpers'
@@ -603,6 +604,19 @@ function AppDetails({
603
604
  </ul>
604
605
  )}
605
606
  </div>
607
+
608
+ {/* Freshness: muted "last checked" line, rarely-needed metadata. The
609
+ backend owns the semantic (freshness.isStale); we only render. */}
610
+ {app.freshness?.lastCheckedAt && (
611
+ <p className="mt-4 text-xs text-muted-foreground">
612
+ <span title={app.freshness.lastCheckedAt}>
613
+ Last checked {formatRelativeTime(app.freshness.lastCheckedAt)}
614
+ </span>
615
+ {app.freshness.isStale && (
616
+ <span className="italic opacity-75"> · may be out of date</span>
617
+ )}
618
+ </p>
619
+ )}
606
620
  </div>
607
621
 
608
622
  {/* Screenshot Gallery Dialog */}
@@ -173,6 +173,27 @@ export function AppCatalogPage({
173
173
  }
174
174
  }, [filteredApps, selectedAppSlug, navigate, searchValue])
175
175
 
176
+ // #22: alias → canonical redirect. When an app is renamed its old slug is
177
+ // kept in `aliases[]`. If the URL slug matches no canonical slug but does
178
+ // match some app's alias, redirect (replace) to that app's canonical slug so
179
+ // old bookmarks resolve instead of showing a blank catalog. Guard on a real
180
+ // canonical miss so we never fight the normal detail-open path. Note: this is
181
+ // a client-side SPA redirect (replace), not an HTTP 301 — see ig-umbrella#22.
182
+ useEffect(() => {
183
+ if (!selectedAppSlug || resources.length === 0) return
184
+ const canonical = resources.some((r) => r.slug === selectedAppSlug)
185
+ if (canonical) return
186
+ const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))
187
+ if (aliased) {
188
+ void navigate({
189
+ to: '/app/$slug',
190
+ params: { slug: aliased.slug },
191
+ search: (prev) => prev,
192
+ replace: true,
193
+ })
194
+ }
195
+ }, [selectedAppSlug, resources, navigate])
196
+
176
197
  const handleAppClick = (app: Resource) => {
177
198
  void navigate({
178
199
  to: '/app/$slug',
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Format an ISO timestamp as a short, human relative phrase ("2 days ago").
3
+ * Used for the muted "Last checked …" line on the app detail view.
4
+ *
5
+ * Kept tiny and dependency-free (Intl.RelativeTimeFormat) — the frontend has no
6
+ * date library and this is the only place that needs relative time.
7
+ */
8
+ const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
9
+ { amount: 60, unit: 'second' },
10
+ { amount: 60, unit: 'minute' },
11
+ { amount: 24, unit: 'hour' },
12
+ { amount: 7, unit: 'day' },
13
+ { amount: 4.34524, unit: 'week' },
14
+ { amount: 12, unit: 'month' },
15
+ { amount: Number.POSITIVE_INFINITY, unit: 'year' },
16
+ ]
17
+
18
+ export function formatRelativeTime(
19
+ iso: string,
20
+ now: number = Date.now(),
21
+ ): string {
22
+ const then = Date.parse(iso)
23
+ if (Number.isNaN(then)) return ''
24
+ const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
25
+ let duration = (then - now) / 1000 // seconds, negative for the past
26
+ for (const division of DIVISIONS) {
27
+ if (Math.abs(duration) < division.amount) {
28
+ return rtf.format(Math.round(duration), division.unit)
29
+ }
30
+ duration /= division.amount
31
+ }
32
+ return rtf.format(Math.round(duration), 'year')
33
+ }