@igstack/app-catalog-frontend-core 2.0.4 → 3.0.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.
Files changed (36) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/utils/areaGrouping.test.d.ts +1 -0
  2. package/dist/esm/__tests__/modules/pwa/PwaAutoUpdateController.test.d.ts +1 -0
  3. package/dist/esm/index.d.ts +2 -1
  4. package/dist/esm/index.js +2 -0
  5. package/dist/esm/index.js.map +1 -1
  6. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +1 -0
  7. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.d.ts +6 -3
  9. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +112 -32
  10. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +1 -0
  12. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  13. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +1 -0
  14. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  15. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +1 -0
  16. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  17. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +0 -5
  18. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/utils/areaGrouping.d.ts +22 -0
  20. package/dist/esm/modules/appCatalog/utils/areaGrouping.js +46 -0
  21. package/dist/esm/modules/appCatalog/utils/areaGrouping.js.map +1 -0
  22. package/dist/esm/modules/pwa/PwaAutoUpdateController.d.ts +8 -0
  23. package/dist/esm/modules/pwa/PwaAutoUpdateController.js +15 -1
  24. package/dist/esm/modules/pwa/PwaAutoUpdateController.js.map +1 -1
  25. package/dist/esm/types/uiSettings.d.ts +28 -0
  26. package/dist/esm/ui/components/header/Header.js +1 -0
  27. package/dist/esm/ui/components/header/Header.js.map +1 -1
  28. package/package.json +4 -4
  29. package/src/__tests__/modules/appCatalog/utils/areaGrouping.test.ts +83 -0
  30. package/src/__tests__/modules/pwa/PwaAutoUpdateController.test.ts +61 -0
  31. package/src/index.tsx +2 -1
  32. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +159 -34
  33. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +0 -10
  34. package/src/modules/appCatalog/utils/areaGrouping.ts +85 -0
  35. package/src/modules/pwa/PwaAutoUpdateController.ts +19 -3
  36. package/src/types/uiSettings.ts +30 -0
@@ -1 +1 @@
1
- {"version":3,"file":"Header.js","sources":["../../../../../src/ui/components/header/Header.tsx"],"sourcesContent":["import type {\n AppVersionInfo,\n VersionInfo,\n} from '@igstack/app-catalog-backend-core'\nimport { Link } from '@tanstack/react-router'\nimport { useQuery } from '@tanstack/react-query'\nimport { LogOut, X } from 'lucide-react'\nimport React, { use } from 'react'\nimport AppCatalogLogo from '~/assets/app-catalog.svg?react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { ThemeSwitcher } from '~/components/ThemeSwitcher'\nimport { AppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'\nimport {\n useAuth,\n useAuthActions,\n useIsAuthenticated,\n useUser,\n} from '~/modules/auth'\nimport { Button } from '~/ui/button'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\nimport {\n Popover,\n PopoverClose,\n PopoverContent,\n PopoverTrigger,\n} from '~/ui/popover'\nimport { Skeleton } from '~/ui/skeleton'\n\nfunction ShaSuffix({ version }: { version: VersionInfo }) {\n if (!version.sha) return null\n if (version.shaUrl) {\n return (\n <>\n {' '}\n <a\n href={version.shaUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n onClick={(e) => e.stopPropagation()}\n >\n ({version.sha})\n </a>\n </>\n )\n }\n return <span className=\"text-muted-foreground\"> ({version.sha})</span>\n}\n\nfunction VersionItem({\n label,\n version,\n}: {\n label: string\n version: VersionInfo\n}) {\n return (\n <div className=\"flex items-center justify-between gap-4 py-1\">\n <span className=\"text-xs text-muted-foreground\">{label}</span>\n <span className=\"text-sm font-medium text-right\">\n {version.url ? (\n <a\n href={version.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:text-primary/80 transition-colors\"\n onClick={(e) => e.stopPropagation()}\n >\n {version.displayName}\n </a>\n ) : (\n version.displayName\n )}\n <ShaSuffix version={version} />\n </span>\n </div>\n )\n}\n\nexport function VersionDisplay({ versions }: { versions: AppVersionInfo }) {\n const isLocal =\n versions.backend?.displayName === 'local' && !versions.coreVersion\n\n if (isLocal) {\n return (\n <span className=\"text-xs text-muted-foreground\" title=\"Local\">\n local\n </span>\n )\n }\n\n return (\n <div className=\"flex flex-col divide-y\">\n {versions.backend && (\n <VersionItem label=\"Pipeline\" version={versions.backend} />\n )}\n {versions.coreVersion && (\n <VersionItem label=\"Core\" version={versions.coreVersion} />\n )}\n {versions.frontend && (\n <VersionItem label=\"FE\" version={versions.frontend} />\n )}\n </div>\n )\n}\n\n/**\n * Version detail moved behind an on-demand popover (same click-to-reveal\n * pattern as PersonBadge's approver chips) instead of always-visible text —\n * keeps the header compact. Content is unchanged: same Pipeline/Core/FE\n * lines, links, and SHA suffix as before, just revealed on click.\n */\nexport function VersionPopover({ versions }: { versions: AppVersionInfo }) {\n const isLocal =\n versions.backend?.displayName === 'local' && !versions.coreVersion\n\n if (isLocal) {\n return (\n <span className=\"text-xs text-muted-foreground\" title=\"Local\">\n local\n </span>\n )\n }\n\n return (\n <Popover>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-mono leading-none text-muted-foreground hover:bg-primary/15 hover:text-primary transition-colors\"\n aria-label=\"Show version details\"\n title=\"Version details\"\n data-testid=\"version-info-trigger\"\n >\n {versions.backend?.displayName ?? '?'}\n </button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"w-72 p-0 overflow-hidden\">\n <div className=\"flex items-center justify-between px-3 py-2 border-b bg-muted/40\">\n <span className=\"text-sm font-semibold\">Version details</span>\n <PopoverClose\n className=\"rounded p-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors\"\n aria-label=\"Close\"\n >\n <X className=\"size-3.5\" />\n </PopoverClose>\n </div>\n <div className=\"px-3 py-1\">\n <VersionDisplay versions={versions} />\n </div>\n </PopoverContent>\n </Popover>\n )\n}\n\nexport interface HeaderProps {\n middle?: React.ReactNode\n}\n\nexport function Header({ middle }: HeaderProps) {\n const { isLoading } = useAuth()\n const isAuthenticated = useIsAuthenticated()\n const user = useUser()\n const { logout, devLogin } = useAuthActions()\n const appCatalogContextMaybe = use(AppCatalogContext)\n const trpc = useTRPC()\n const { data: providersData } = useQuery(\n trpc.auth.getProviders.queryOptions(),\n )\n\n const handleLogout = async () => {\n try {\n await logout()\n } catch (error) {\n console.error('Logout failed:', error)\n }\n }\n\n return (\n <div className=\"flex items-center mb-4 pb-4 gap-4 border-b\">\n <div className=\"flex items-center gap-4\">\n <div className=\"flex items-center gap-2\">\n <Link to=\"/\">\n <AppCatalogLogo className=\"h-16 w-16\" />\n </Link>\n <div className=\"flex flex-col\">\n <Link to=\"/\" className=\"font-serif text-lg font-semibold\">\n App Catalog\n </Link>\n <div className=\"flex items-center gap-1.5\">\n <span className=\"text-[10px] text-muted-foreground/60\">\n by Igor Golovin\n </span>\n {appCatalogContextMaybe?.versions && (\n <VersionPopover versions={appCatalogContextMaybe.versions} />\n )}\n </div>\n </div>\n </div>\n </div>\n {middle && <div className=\"flex-1\">{middle}</div>}\n <div className=\"flex items-center gap-3 ml-auto\">\n <ThemeSwitcher />\n {isLoading ? (\n <Skeleton className=\"w-8 h-8 rounded-full\" />\n ) : !isAuthenticated ? (\n // No Login button: the catalog is fully browsable without signing in,\n // so it mostly advertised a flow most visitors have no reason to\n // enter. Auth itself is untouched -- the modal, the login route, the\n // client and session handling all still work, so anyone who reaches\n // the flow by URL or by calling `useAuthModal().open()` can sign in,\n // and this branch flips to the user menu below once they do. `git\n // revert` of the commit that removed it restores the button as-is.\n // DEV Login stays: it is gated behind `devLoginEnabled`, so it never\n // reaches a real deployment.\n providersData?.devLoginEnabled && (\n <button\n type=\"button\"\n onClick={devLogin}\n className=\"text-xs font-medium px-2 py-1 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 cursor-pointer\"\n >\n DEV Login\n </button>\n )\n ) : (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-8 h-8 rounded-full p-0 hover:bg-accent hover:text-accent-foreground cursor-pointer\"\n aria-label={`User menu for ${user?.name || 'User'}`}\n data-testid=\"user-avatar-button\"\n >\n {user?.image ? (\n <img\n src={user.image}\n alt={user.name || 'User'}\n className=\"w-full h-full rounded-full object-cover\"\n />\n ) : (\n <div className=\"flex items-center justify-center w-full h-full rounded-full bg-primary text-primary-foreground text-xs font-semibold\">\n {(user?.name || user?.email || '?').charAt(0).toUpperCase()}\n </div>\n )}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-56\">\n <div className=\"px-2 py-3 flex items-center gap-3\">\n {user?.image ? (\n <img\n src={user.image}\n alt={user.name || 'User'}\n className=\"w-10 h-10 rounded-full object-cover shrink-0\"\n />\n ) : (\n <div className=\"flex items-center justify-center w-10 h-10 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0\">\n {(user?.name || user?.email || '?').charAt(0).toUpperCase()}\n </div>\n )}\n <div className=\"flex flex-col min-w-0\">\n <p className=\"text-xs text-muted-foreground truncate\">\n @{user?.email?.split('@')[0] || 'user'}\n </p>\n <p className=\"text-sm font-medium truncate\">\n {user?.name || 'User'}\n </p>\n </div>\n </div>\n <DropdownMenuSeparator />\n <DropdownMenuItem onClick={handleLogout}>\n <LogOut className=\"h-4 w-4 mr-2\" />\n <span>Sign out</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n )}\n </div>\n </div>\n )\n}\n"],"names":["AppCatalogLogo"],"mappings":"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"}
1
+ {"version":3,"file":"Header.js","sources":["../../../../../src/ui/components/header/Header.tsx"],"sourcesContent":["import type {\n AppVersionInfo,\n VersionInfo,\n} from '@igstack/app-catalog-backend-core'\nimport { Link } from '@tanstack/react-router'\nimport { useQuery } from '@tanstack/react-query'\nimport { LogOut, X } from 'lucide-react'\nimport React, { use } from 'react'\nimport AppCatalogLogo from '~/assets/app-catalog.svg?react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { ThemeSwitcher } from '~/components/ThemeSwitcher'\nimport { AppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'\nimport {\n useAuth,\n useAuthActions,\n useIsAuthenticated,\n useUser,\n} from '~/modules/auth'\nimport { Button } from '~/ui/button'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\nimport {\n Popover,\n PopoverClose,\n PopoverContent,\n PopoverTrigger,\n} from '~/ui/popover'\nimport { Skeleton } from '~/ui/skeleton'\n\nfunction ShaSuffix({ version }: { version: VersionInfo }) {\n if (!version.sha) return null\n if (version.shaUrl) {\n return (\n <>\n {' '}\n <a\n href={version.shaUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n onClick={(e) => e.stopPropagation()}\n >\n ({version.sha})\n </a>\n </>\n )\n }\n return <span className=\"text-muted-foreground\"> ({version.sha})</span>\n}\n\nfunction VersionItem({\n label,\n version,\n}: {\n label: string\n version: VersionInfo\n}) {\n return (\n <div className=\"flex items-center justify-between gap-4 py-1\">\n <span className=\"text-xs text-muted-foreground\">{label}</span>\n <span className=\"text-sm font-medium text-right\">\n {version.url ? (\n <a\n href={version.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:text-primary/80 transition-colors\"\n onClick={(e) => e.stopPropagation()}\n >\n {version.displayName}\n </a>\n ) : (\n version.displayName\n )}\n <ShaSuffix version={version} />\n </span>\n </div>\n )\n}\n\nexport function VersionDisplay({ versions }: { versions: AppVersionInfo }) {\n const isLocal =\n versions.backend?.displayName === 'local' && !versions.coreVersion\n\n if (isLocal) {\n return (\n <span className=\"text-xs text-muted-foreground\" title=\"Local\">\n local\n </span>\n )\n }\n\n return (\n <div className=\"flex flex-col divide-y\">\n {versions.backend && (\n <VersionItem label=\"Pipeline\" version={versions.backend} />\n )}\n {versions.coreVersion && (\n <VersionItem label=\"Core\" version={versions.coreVersion} />\n )}\n {versions.frontend && (\n <VersionItem label=\"FE\" version={versions.frontend} />\n )}\n </div>\n )\n}\n\n/**\n * Version detail moved behind an on-demand popover (same click-to-reveal\n * pattern as PersonBadge's approver chips) instead of always-visible text —\n * keeps the header compact. Content is unchanged: same Pipeline/Core/FE\n * lines, links, and SHA suffix as before, just revealed on click.\n */\nexport function VersionPopover({ versions }: { versions: AppVersionInfo }) {\n const isLocal =\n versions.backend?.displayName === 'local' && !versions.coreVersion\n\n if (isLocal) {\n return (\n <span className=\"text-xs text-muted-foreground\" title=\"Local\">\n local\n </span>\n )\n }\n\n return (\n <Popover>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-mono leading-none text-muted-foreground hover:bg-primary/15 hover:text-primary transition-colors\"\n aria-label=\"Show version details\"\n title=\"Version details\"\n data-testid=\"version-info-trigger\"\n >\n {versions.backend?.displayName ?? '?'}\n </button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"w-72 p-0 overflow-hidden\">\n <div className=\"flex items-center justify-between px-3 py-2 border-b bg-muted/40\">\n <span className=\"text-sm font-semibold\">Version details</span>\n <PopoverClose\n className=\"rounded p-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors\"\n aria-label=\"Close\"\n >\n <X className=\"size-3.5\" />\n </PopoverClose>\n </div>\n <div className=\"px-3 py-1\">\n <VersionDisplay versions={versions} />\n </div>\n </PopoverContent>\n </Popover>\n )\n}\n\nexport interface HeaderProps {\n middle?: React.ReactNode\n}\n\nexport function Header({ middle }: HeaderProps) {\n const { isLoading } = useAuth()\n const isAuthenticated = useIsAuthenticated()\n const user = useUser()\n const { logout, devLogin } = useAuthActions()\n const appCatalogContextMaybe = use(AppCatalogContext)\n const trpc = useTRPC()\n const { data: providersData } = useQuery(\n trpc.auth.getProviders.queryOptions(),\n )\n\n const handleLogout = async () => {\n try {\n await logout()\n } catch (error) {\n console.error('Logout failed:', error)\n }\n }\n\n return (\n <div className=\"flex items-center mb-4 pb-4 gap-4 border-b\">\n <div className=\"flex items-center gap-4\">\n <div className=\"flex items-center gap-2\">\n <Link to=\"/\">\n <AppCatalogLogo className=\"h-16 w-16\" />\n </Link>\n <div className=\"flex flex-col\">\n <Link to=\"/\" className=\"font-serif text-lg font-semibold\">\n App Catalog\n </Link>\n <div className=\"flex items-center gap-1.5\">\n <span className=\"text-[10px] text-muted-foreground/60\">\n by Igor Golovin\n </span>\n {appCatalogContextMaybe?.versions && (\n <VersionPopover versions={appCatalogContextMaybe.versions} />\n )}\n </div>\n </div>\n </div>\n </div>\n {middle && <div className=\"flex-1\">{middle}</div>}\n <div className=\"flex items-center gap-3 ml-auto\">\n <ThemeSwitcher />\n {isLoading ? (\n <Skeleton className=\"w-8 h-8 rounded-full\" />\n ) : !isAuthenticated ? (\n // No Login button: the catalog is fully browsable without signing in,\n // so it mostly advertised a flow most visitors have no reason to\n // enter. Auth itself is untouched -- the modal, the login route, the\n // client and session handling all still work, so anyone who reaches\n // the flow by URL or by calling `useAuthModal().open()` can sign in,\n // and this branch flips to the user menu below once they do. `git\n // revert` of the commit that removed it restores the button as-is.\n // DEV Login stays: it is gated behind `devLoginEnabled`, so it never\n // reaches a real deployment.\n providersData?.devLoginEnabled && (\n <button\n type=\"button\"\n onClick={devLogin}\n className=\"text-xs font-medium px-2 py-1 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 cursor-pointer\"\n >\n DEV Login\n </button>\n )\n ) : (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-8 h-8 rounded-full p-0 hover:bg-accent hover:text-accent-foreground cursor-pointer\"\n aria-label={`User menu for ${user?.name || 'User'}`}\n data-testid=\"user-avatar-button\"\n >\n {user?.image ? (\n <img\n src={user.image}\n alt={user.name || 'User'}\n className=\"w-full h-full rounded-full object-cover\"\n />\n ) : (\n <div className=\"flex items-center justify-center w-full h-full rounded-full bg-primary text-primary-foreground text-xs font-semibold\">\n {(user?.name || user?.email || '?').charAt(0).toUpperCase()}\n </div>\n )}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-56\">\n <div className=\"px-2 py-3 flex items-center gap-3\">\n {user?.image ? (\n <img\n src={user.image}\n alt={user.name || 'User'}\n className=\"w-10 h-10 rounded-full object-cover shrink-0\"\n />\n ) : (\n <div className=\"flex items-center justify-center w-10 h-10 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0\">\n {(user?.name || user?.email || '?').charAt(0).toUpperCase()}\n </div>\n )}\n <div className=\"flex flex-col min-w-0\">\n <p className=\"text-xs text-muted-foreground truncate\">\n @{user?.email?.split('@')[0] || 'user'}\n </p>\n <p className=\"text-sm font-medium truncate\">\n {user?.name || 'User'}\n </p>\n </div>\n </div>\n <DropdownMenuSeparator />\n <DropdownMenuItem onClick={handleLogout}>\n <LogOut className=\"h-4 w-4 mr-2\" />\n <span>Sign out</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n )}\n </div>\n </div>\n )\n}\n"],"names":["AppCatalogLogo"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "2.0.4",
3
+ "version": "3.0.0",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -113,7 +113,7 @@
113
113
  "tailwindcss": "^4.1.7",
114
114
  "tiptap-markdown": "^0.8.0",
115
115
  "tslib": "^2.3.0",
116
- "@igstack/app-catalog-shared-core": "2.0.4"
116
+ "@igstack/app-catalog-shared-core": "3.0.0"
117
117
  },
118
118
  "devDependencies": {
119
119
  "@tailwindcss/vite": "^4.0.5",
@@ -140,7 +140,7 @@
140
140
  "vite-plugin-static-copy": "^3.1.4",
141
141
  "vite-plugin-svgr": "^4.2.0",
142
142
  "vitest": "^4.1.2",
143
- "@igstack/app-catalog-backend-core": "2.0.4"
143
+ "@igstack/app-catalog-backend-core": "3.0.0"
144
144
  },
145
145
  "peerDependencies": {
146
146
  "react": "19.1.2",
@@ -149,7 +149,7 @@
149
149
  "vite": "^6.3.5",
150
150
  "vite-plugin-svgr": "^4.2.0"
151
151
  },
152
- "gitHead": "d52df9105e77a6e1efdecc74faf291b237bd0363",
152
+ "gitHead": "9762dd89f7623b15f0507f9936308720b58527ab",
153
153
  "scripts": {
154
154
  "build": "vite build",
155
155
  "build:lenient": "vite build --mode lenient",
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type {
3
+ GroupingTagDefinition,
4
+ Resource,
5
+ } from '@igstack/app-catalog-backend-core'
6
+ import {
7
+ DAY_TO_DAY_AREA_KEY,
8
+ areaLabel,
9
+ categoryOf,
10
+ groupByArea,
11
+ } from '~/modules/appCatalog/utils/areaGrouping'
12
+
13
+ const app = (slug: string, tags: string[] = []) =>
14
+ ({ slug, displayName: slug, tags }) as Resource
15
+
16
+ const definitions: GroupingTagDefinition[] = [
17
+ {
18
+ prefix: 'category',
19
+ displayName: 'Category',
20
+ description: '',
21
+ values: [
22
+ { value: 'finance', displayName: 'Money things', description: '' },
23
+ { value: 'perks', displayName: 'Perks', description: '' },
24
+ ],
25
+ },
26
+ ]
27
+
28
+ describe('categoryOf', () => {
29
+ it('falls back to "other" without a category tag', () => {
30
+ expect(categoryOf(app('a', ['universality:common']))).toBe('other')
31
+ })
32
+ })
33
+
34
+ describe('groupByArea', () => {
35
+ it('puts the day-to-day group first, then areas biggest-first', () => {
36
+ const groups = groupByArea([
37
+ app('f1', ['category:finance']),
38
+ app('s1', ['category:security']),
39
+ app('f2', ['category:finance']),
40
+ app('everyone', ['category:security', 'universality:everyone']),
41
+ ])
42
+ expect(groups.map(([key, list]) => [key, list.length])).toEqual([
43
+ [DAY_TO_DAY_AREA_KEY, 1],
44
+ ['finance', 2],
45
+ ['security', 1],
46
+ ])
47
+ })
48
+
49
+ it('folds a configured category into the day-to-day group', () => {
50
+ const groups = groupByArea(
51
+ [app('lunch', ['category:perks']), app('bank', ['category:finance'])],
52
+ ['perks'],
53
+ )
54
+ expect(groups.map(([key]) => key)).toEqual([DAY_TO_DAY_AREA_KEY, 'finance'])
55
+ })
56
+
57
+ it('omits the day-to-day group when nothing belongs there', () => {
58
+ expect(groupByArea([app('bank', ['category:finance'])])).toEqual([
59
+ ['finance', [expect.objectContaining({ slug: 'bank' })]],
60
+ ])
61
+ })
62
+ })
63
+
64
+ describe('areaLabel', () => {
65
+ it('uses the catalog display name for a known category', () => {
66
+ expect(areaLabel('finance', definitions)).toBe('Money things')
67
+ })
68
+
69
+ it('humanizes an unknown category slug', () => {
70
+ expect(areaLabel('field_service', definitions)).toBe('Field service')
71
+ })
72
+
73
+ it('labels the day-to-day group, overridable', () => {
74
+ expect(areaLabel(DAY_TO_DAY_AREA_KEY, definitions)).toBe('Day-to-day tools')
75
+ expect(areaLabel(DAY_TO_DAY_AREA_KEY, definitions, 'Everyday')).toBe(
76
+ 'Everyday',
77
+ )
78
+ })
79
+
80
+ it('works with no definitions at all', () => {
81
+ expect(areaLabel('finance')).toBe('Finance')
82
+ })
83
+ })
@@ -0,0 +1,61 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { PwaAutoUpdateController } from '~/modules/pwa/PwaAutoUpdateController'
3
+
4
+ function makeRegistration(hasWaiting: boolean) {
5
+ const postMessage = vi.fn()
6
+ const registration = {
7
+ update: vi.fn().mockResolvedValue(undefined),
8
+ waiting: hasWaiting ? { postMessage } : null,
9
+ } as unknown as ServiceWorkerRegistration
10
+
11
+ return { registration, postMessage }
12
+ }
13
+
14
+ function makeController(registration: ServiceWorkerRegistration) {
15
+ const updateSW = vi.fn()
16
+ const controller = new PwaAutoUpdateController(
17
+ { updateSW, registration },
18
+ { minCheckIntervalMs: 0 },
19
+ )
20
+ return { controller, updateSW }
21
+ }
22
+
23
+ describe('PwaAutoUpdateController', () => {
24
+ beforeEach(() => {
25
+ vi.useRealTimers()
26
+ })
27
+
28
+ it('asks a waiting worker to take over after checking for an update', async () => {
29
+ const { registration, postMessage } = makeRegistration(true)
30
+ const { controller } = makeController(registration)
31
+
32
+ // @ts-expect-error -- exercising the private check the timers/visibility call
33
+ await controller.checkForUpdate()
34
+
35
+ expect(registration.update).toHaveBeenCalledOnce()
36
+ expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' })
37
+ })
38
+
39
+ it('does nothing extra when no new worker is waiting', async () => {
40
+ const { registration } = makeRegistration(false)
41
+ const { controller } = makeController(registration)
42
+
43
+ // @ts-expect-error -- exercising the private check the timers/visibility call
44
+ await expect(controller.checkForUpdate()).resolves.toBeUndefined()
45
+
46
+ expect(registration.update).toHaveBeenCalledOnce()
47
+ })
48
+
49
+ it('activates a waiting worker before the error-triggered reload', async () => {
50
+ const { registration, postMessage } = makeRegistration(true)
51
+ const { controller, updateSW } = makeController(registration)
52
+
53
+ vi.useFakeTimers()
54
+ const done = controller.triggerUpdateOnError()
55
+ await vi.runAllTimersAsync()
56
+ await done
57
+
58
+ expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' })
59
+ expect(updateSW).toHaveBeenCalledWith(true)
60
+ })
61
+ })
package/src/index.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  export { appPropsFactory } from './appPropsFactory.js'
2
2
  export { App } from './App'
3
3
  export type { AppProps } from './App'
4
- export type { UiSettings } from './types/uiSettings'
4
+ export type { AreaIcon, AreasSettings, UiSettings } from './types/uiSettings'
5
+ export { DAY_TO_DAY_AREA_KEY } from './modules/appCatalog/utils/areaGrouping'
5
6
  export { PwaAutoUpdateProvider, usePwaAutoUpdate } from './modules/pwa'
6
7
  export type { PwaAutoUpdateOptions, PwaUpdateHandle } from './modules/pwa'
@@ -1,6 +1,6 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
2
  import { ArrowUpRight, Search, X } from 'lucide-react'
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+ import { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { cn } from '~/lib/utils'
5
5
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
6
6
  import { markdownToPlainText } from '../../utils/markdownToPlainText'
@@ -10,11 +10,15 @@ import { ResourceIcon } from './ResourceIcon'
10
10
  import { searchResources } from '@igstack/app-catalog-shared-core'
11
11
  import { Highlight } from '../components/Highlight'
12
12
  import { displayUrl } from '~/modules/appCatalog/utils/displayUrl'
13
+ import { useUiSettings } from '~/context/UiSettingsContext'
14
+ import type { AreaIcon } from '~/types/uiSettings'
15
+ import { AppCatalogContext } from '../../context/AppCatalogContext'
16
+ import { areaLabel, groupByArea } from '../../utils/areaGrouping'
13
17
 
14
18
  /**
15
19
  * Adaptive-home discovery spine (issue #38, increment 1) — matches the
16
20
  * option-a-launcher prototype:
17
- * Your apps (frequent) → New this week → Browse all
21
+ * Your apps (frequent) → New this week → all areas, grouped
18
22
  *
19
23
  * This is the PERSISTENT shell for the catalog: idle browse, active search and
20
24
  * an open detail overlay all render inside it. Only the area below the hero
@@ -47,8 +51,11 @@ export interface AppCatalogGridProps {
47
51
  /** Opens a matched sub-resource: its parent's detail, singled out to it. */
48
52
  onSubClick: (parentSlug: string, subSlug: string) => void
49
53
  onLaunch: (app: Resource) => void
50
- /** Total resource count for the "Browse all" label. */
51
- totalCount: number
54
+ /**
55
+ * @deprecated Unused — it labelled the removed "Browse all" header. Still
56
+ * accepted so existing callers keep compiling; drop it on the next major.
57
+ */
58
+ totalCount?: number
52
59
  /**
53
60
  * True while an app/sub-resource detail overlay is open above the launcher.
54
61
  * The results list then stops binding ↑↓/↵/Esc so the overlay owns the
@@ -118,7 +125,7 @@ function SectionHead({
118
125
  )
119
126
  }
120
127
 
121
- /** Small row used by "New this week" and "Browse all". */
128
+ /** Small row used by "New this week" and the search results. */
122
129
  function ResourceRow({
123
130
  app,
124
131
  onAppClick,
@@ -167,6 +174,129 @@ function ResourceRow({
167
174
  )
168
175
  }
169
176
 
177
+ /**
178
+ * Wide area card: icon on the left, name and description beside it.
179
+ *
180
+ * The grid cell keeps a fixed footprint and the visible panel is absolute inside
181
+ * it, so a card growing on hover OVERLAYS the row below instead of reflowing the
182
+ * whole grid. Hover reveals the rest of the description and nothing else — an
183
+ * earlier version staggered a delayed tag block in behind the growth, which read
184
+ * as jitter. Keyboard focus expands the same way, and `motion-reduce` drops the
185
+ * animation.
186
+ *
187
+ * The footprint is a fixed height rather than an aspect ratio: at two or three
188
+ * columns a ratio makes the card grow taller as the window widens, and the
189
+ * description — three lines at most — leaves the bottom half empty.
190
+ */
191
+ function AppCard({
192
+ app,
193
+ onAppClick,
194
+ onLaunch,
195
+ }: {
196
+ app: Resource
197
+ onAppClick: (app: Resource) => void
198
+ onLaunch: (app: Resource) => void
199
+ }) {
200
+ return (
201
+ <div className="relative h-[92px]">
202
+ <button
203
+ type="button"
204
+ onClick={() => onAppClick(app)}
205
+ title={`View ${app.displayName}`}
206
+ className={cn(
207
+ 'group absolute inset-x-0 top-0 min-h-full max-h-full overflow-hidden',
208
+ 'flex gap-3.5 text-left bg-card border border-border',
209
+ 'rounded-[var(--radius)] p-[15px] pr-11 outline-none',
210
+ 'transition-[max-height,box-shadow,border-color] duration-200',
211
+ 'hover:max-h-96 hover:border-ring hover:shadow-lg hover:z-10',
212
+ 'focus-visible:max-h-96 focus-visible:border-ring focus-visible:shadow-lg focus-visible:z-10',
213
+ 'motion-reduce:transition-none',
214
+ )}
215
+ >
216
+ <LaunchButton
217
+ app={app}
218
+ onLaunch={onLaunch}
219
+ className="absolute top-3 right-3 size-[26px] opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
220
+ />
221
+ <div className="shrink-0">
222
+ <ResourceIcon app={app} size={40} />
223
+ </div>
224
+ {/* min-w-0 so a long unbroken word truncates instead of pushing the
225
+ column wider than the card. */}
226
+ <div className="flex min-w-0 flex-col gap-1.5">
227
+ {/* shrink-0: while max-height animates, the un-clamped description
228
+ overflows the panel for a few frames. Without it flexbox squeezes
229
+ this line to 0 and back, which reads as the title flickering. */}
230
+ <span className="block shrink-0 text-[14.5px] font-bold leading-tight line-clamp-1 group-hover:line-clamp-2 group-focus-visible:line-clamp-2">
231
+ {app.displayName}
232
+ </span>
233
+ {app.description && (
234
+ <span
235
+ className={cn(
236
+ 'block shrink-0 text-[12.5px] leading-snug text-muted-foreground',
237
+ 'line-clamp-2 group-hover:line-clamp-none group-focus-visible:line-clamp-none',
238
+ )}
239
+ >
240
+ {markdownToPlainText(app.description)}
241
+ </span>
242
+ )}
243
+ </div>
244
+ </button>
245
+ </div>
246
+ )
247
+ }
248
+
249
+ /**
250
+ * One area: icon, big title, count, then its cards. The icon stands in for the
251
+ * accent bar the header used to carry — it does the same colour job and also
252
+ * says which area you are in. It is decorative: the `h3` carries the name.
253
+ */
254
+ function AreaSection({
255
+ label,
256
+ Icon,
257
+ apps,
258
+ onAppClick,
259
+ onLaunch,
260
+ }: {
261
+ label: string
262
+ Icon?: AreaIcon
263
+ apps: Resource[]
264
+ onAppClick: (app: Resource) => void
265
+ onLaunch: (app: Resource) => void
266
+ }) {
267
+ return (
268
+ <section className="mt-11">
269
+ <div className="flex items-center gap-2.5 mb-4">
270
+ {Icon ? (
271
+ <Icon
272
+ size={26}
273
+ strokeWidth={1.75}
274
+ className="text-primary shrink-0"
275
+ />
276
+ ) : (
277
+ <div className="h-[3px] w-11 rounded-full bg-primary" />
278
+ )}
279
+ <h3 className="font-serif font-semibold text-[27px] leading-tight tracking-tight m-0">
280
+ {label}
281
+ </h3>
282
+ <span className="text-[13px] text-muted-foreground">
283
+ {apps.length} {apps.length === 1 ? 'tool' : 'tools'}
284
+ </span>
285
+ </div>
286
+ <div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
287
+ {apps.map((app) => (
288
+ <AppCard
289
+ key={app.slug}
290
+ app={app}
291
+ onAppClick={onAppClick}
292
+ onLaunch={onLaunch}
293
+ />
294
+ ))}
295
+ </div>
296
+ </section>
297
+ )
298
+ }
299
+
170
300
  /** Matched sub-resources listed under a parent before the "… N more" row. */
171
301
  const COLLAPSED_SUB_LIMIT = 5
172
302
 
@@ -487,12 +617,15 @@ export function AppCatalogGrid({
487
617
  onAppClick,
488
618
  onSubClick,
489
619
  onLaunch,
490
- totalCount,
491
620
  detailOpen = false,
492
621
  selectedSubSlug,
493
622
  }: AppCatalogGridProps) {
494
623
  const { getTopApps } = useAppClickHistory()
495
624
  const [topSlugs, setTopSlugs] = useState<string[]>([])
625
+ // Area labels are data (the catalog's own `category` tag definition); only the
626
+ // icons and the day-to-day fold are deployment config.
627
+ const { areas } = useUiSettings()
628
+ const tagsDefinitions = use(AppCatalogContext)?.tagsDefinitions
496
629
 
497
630
  useEffect(() => {
498
631
  void getTopApps(5).then(setTopSlugs)
@@ -517,7 +650,7 @@ export function AppCatalogGrid({
517
650
  // New this week: apps ADDED in the last 7 days — see pickNewThisWeek (#96).
518
651
  const fresh = useMemo(() => pickNewThisWeek(apps), [apps])
519
652
 
520
- // Browse all: everything not already promoted to "Your apps", alpha by name.
653
+ // Everything not already promoted to "Your apps", alpha by name.
521
654
  const browse = useMemo(() => {
522
655
  const promoted = new Set(frequent.map((a) => a.slug))
523
656
  return [...apps]
@@ -525,8 +658,12 @@ export function AppCatalogGrid({
525
658
  .sort((a, b) => a.displayName.localeCompare(b.displayName))
526
659
  }, [apps, frequent])
527
660
 
528
- const browseLeft = browse.filter((_, i) => i % 2 === 0)
529
- const browseRight = browse.filter((_, i) => i % 2 === 1)
661
+ // Grouped: day-to-day tools first, then areas biggest-first so the page opens
662
+ // on the areas most people need. Within an area, alpha (from browse).
663
+ const browseGroups = useMemo(
664
+ () => groupByArea(browse, areas?.dayToDayCategories),
665
+ [browse, areas?.dayToDayCategories],
666
+ )
530
667
 
531
668
  const isSearching = searchValue.trim() !== ''
532
669
  const searchRef = useRef<HTMLInputElement>(null)
@@ -560,7 +697,7 @@ export function AppCatalogGrid({
560
697
  type="text"
561
698
  value={searchValue}
562
699
  onChange={(e) => onSearchChange(e.target.value)}
563
- placeholder="Search apps, databases, cloud accounts, account IDs…"
700
+ placeholder="Search apps, cloud accounts, or keywords"
564
701
  autoComplete="off"
565
702
  autoFocus
566
703
  ref={searchRef}
@@ -663,31 +800,19 @@ export function AppCatalogGrid({
663
800
  </section>
664
801
  )}
665
802
 
666
- {/* Browse all */}
803
+ {/* Everything else, grouped by area — the area titles carry it, so
804
+ there is no "Browse all" header above them. */}
667
805
  <section className="mt-9">
668
- <SectionHead title="Browse all" count={`${totalCount} resources`} />
669
- <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
670
- <div className="flex flex-col gap-2">
671
- {browseLeft.map((app) => (
672
- <ResourceRow
673
- key={app.slug}
674
- app={app}
675
- onAppClick={onAppClick}
676
- onLaunch={onLaunch}
677
- />
678
- ))}
679
- </div>
680
- <div className="flex flex-col gap-2">
681
- {browseRight.map((app) => (
682
- <ResourceRow
683
- key={app.slug}
684
- app={app}
685
- onAppClick={onAppClick}
686
- onLaunch={onLaunch}
687
- />
688
- ))}
689
- </div>
690
- </div>
806
+ {browseGroups.map(([key, list]) => (
807
+ <AreaSection
808
+ key={key}
809
+ label={areaLabel(key, tagsDefinitions, areas?.dayToDayLabel)}
810
+ Icon={areas?.icons?.[key]}
811
+ apps={list}
812
+ onAppClick={onAppClick}
813
+ onLaunch={onLaunch}
814
+ />
815
+ ))}
691
816
  </section>
692
817
 
693
818
  <AttributionFooter />
@@ -133,15 +133,6 @@ export function AppCatalogPage({
133
133
  )
134
134
  }, [subPageSlug, selectedApp, resources])
135
135
 
136
- // Total resource count for the "Browse all" label (respects ?deprecated=1)
137
- const totalAppsCount = useMemo(
138
- () =>
139
- filterState.showDeprecated
140
- ? rootResources.length
141
- : rootResources.filter((app) => !app.deprecated).length,
142
- [rootResources, filterState.showDeprecated],
143
- )
144
-
145
136
  if (isLoadingApps) {
146
137
  return <div className="py-6 text-muted-foreground">Loading…</div>
147
138
  }
@@ -166,7 +157,6 @@ export function AppCatalogPage({
166
157
  onAppClick={handleAppClick}
167
158
  onSubClick={handleSubClick}
168
159
  onLaunch={handleLaunch}
169
- totalCount={totalAppsCount}
170
160
  detailOpen={selectedApp !== null}
171
161
  selectedSubSlug={highlightSubSlug}
172
162
  />
@@ -0,0 +1,85 @@
1
+ import type {
2
+ GroupingTagDefinition,
3
+ Resource,
4
+ } from '@igstack/app-catalog-backend-core'
5
+
6
+ /**
7
+ * Grouping of the full resource list into areas.
8
+ *
9
+ * The vocabulary itself is NOT here: which `category:<value>` tags exist, what
10
+ * they are called and which of them belong on the day-to-day shelf is
11
+ * deployment-specific. Labels come from the catalog's own tag definitions and
12
+ * icons from `UiSettings.areas`, so the core only knows the mechanism.
13
+ */
14
+
15
+ const CATEGORY_PREFIX = 'category:'
16
+ const EVERYONE_TAG = 'universality:everyone'
17
+
18
+ /**
19
+ * Synthetic key for the merged first group. Underscored so it cannot collide
20
+ * with a real `category:<value>`.
21
+ */
22
+ export const DAY_TO_DAY_AREA_KEY = '__day-to-day__'
23
+
24
+ /** Fallback area for resources carrying no `category:` tag. */
25
+ export const OTHER_AREA_KEY = 'other'
26
+
27
+ export const categoryOf = (app: Resource): string =>
28
+ app.tags
29
+ ?.find((t) => t.startsWith(CATEGORY_PREFIX))
30
+ ?.slice(CATEGORY_PREFIX.length) ?? OTHER_AREA_KEY
31
+
32
+ const humanize = (key: string): string => {
33
+ const words = key.replace(/[-_]+/g, ' ').trim()
34
+ return words.charAt(0).toUpperCase() + words.slice(1)
35
+ }
36
+
37
+ /**
38
+ * Day-to-day tools first, then one area per category, biggest area first.
39
+ * Input order is preserved inside each group.
40
+ *
41
+ * `dayToDayCategories` are categories that fold into the day-to-day group
42
+ * instead of getting an area of their own (perks, office services, …).
43
+ */
44
+ export function groupByArea(
45
+ apps: Resource[],
46
+ dayToDayCategories: readonly string[] = [],
47
+ ): [string, Resource[]][] {
48
+ const folded = new Set(dayToDayCategories)
49
+ const dayToDay: Resource[] = []
50
+ const byCategory = new Map<string, Resource[]>()
51
+
52
+ for (const app of apps) {
53
+ const key = categoryOf(app)
54
+ if (app.tags?.includes(EVERYONE_TAG) || folded.has(key)) {
55
+ dayToDay.push(app)
56
+ continue
57
+ }
58
+ const list = byCategory.get(key)
59
+ if (list) list.push(app)
60
+ else byCategory.set(key, [app])
61
+ }
62
+
63
+ const areas = [...byCategory.entries()].sort(
64
+ (a, b) => b[1].length - a[1].length,
65
+ )
66
+ return dayToDay.length
67
+ ? [[DAY_TO_DAY_AREA_KEY, dayToDay] as [string, Resource[]], ...areas]
68
+ : areas
69
+ }
70
+
71
+ /**
72
+ * Title for an area. The catalog's `category` tag definition supplies the
73
+ * display names; an unknown value still gets a readable humanized slug.
74
+ */
75
+ export function areaLabel(
76
+ key: string,
77
+ tagsDefinitions: readonly GroupingTagDefinition[] = [],
78
+ dayToDayLabel = 'Day-to-day tools',
79
+ ): string {
80
+ if (key === DAY_TO_DAY_AREA_KEY) return dayToDayLabel
81
+ const defined = tagsDefinitions
82
+ .find((d) => d.prefix === 'category')
83
+ ?.values.find((v) => v.value === key)?.displayName
84
+ return defined || humanize(key)
85
+ }