@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.
- package/dist/esm/__tests__/modules/appCatalog/utils/areaGrouping.test.d.ts +1 -0
- package/dist/esm/__tests__/modules/pwa/PwaAutoUpdateController.test.d.ts +1 -0
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +1 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.d.ts +6 -3
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +112 -32
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +0 -5
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/areaGrouping.d.ts +22 -0
- package/dist/esm/modules/appCatalog/utils/areaGrouping.js +46 -0
- package/dist/esm/modules/appCatalog/utils/areaGrouping.js.map +1 -0
- package/dist/esm/modules/pwa/PwaAutoUpdateController.d.ts +8 -0
- package/dist/esm/modules/pwa/PwaAutoUpdateController.js +15 -1
- package/dist/esm/modules/pwa/PwaAutoUpdateController.js.map +1 -1
- package/dist/esm/types/uiSettings.d.ts +28 -0
- package/dist/esm/ui/components/header/Header.js +1 -0
- package/dist/esm/ui/components/header/Header.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/utils/areaGrouping.test.ts +83 -0
- package/src/__tests__/modules/pwa/PwaAutoUpdateController.test.ts +61 -0
- package/src/index.tsx +2 -1
- package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +159 -34
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +0 -10
- package/src/modules/appCatalog/utils/areaGrouping.ts +85 -0
- package/src/modules/pwa/PwaAutoUpdateController.ts +19 -3
- 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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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 →
|
|
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
|
-
/**
|
|
51
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
529
|
-
|
|
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,
|
|
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
|
-
{/*
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
{
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
+
}
|