@igstack/app-catalog-frontend-core 2.0.4 → 3.0.0-alpha-20260917212201
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/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/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/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
|
@@ -27,6 +27,14 @@ export declare class PwaAutoUpdateController {
|
|
|
27
27
|
private onUserActivity;
|
|
28
28
|
private checkIdleState;
|
|
29
29
|
private checkForUpdate;
|
|
30
|
+
/**
|
|
31
|
+
* A freshly installed worker stays in `waiting` for as long as this page is
|
|
32
|
+
* controlled by the old one — a reload does not release it, only closing
|
|
33
|
+
* every tab on the origin does. Ask it to take over now; the generated
|
|
34
|
+
* worker answers this message with `skipWaiting()`, and the registration's
|
|
35
|
+
* own `activated` listener reloads the page onto the new build.
|
|
36
|
+
*/
|
|
37
|
+
private activateWaitingWorker;
|
|
30
38
|
private reload;
|
|
31
39
|
private log;
|
|
32
40
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const DEFAULT_IDLE_TIMEOUT_MS =
|
|
1
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 60 * 1e3;
|
|
2
2
|
const DEFAULT_MIN_CHECK_INTERVAL_MS = 30 * 1e3;
|
|
3
3
|
const VISIBILITY_DEBOUNCE_MS = 1e3;
|
|
4
4
|
const ERROR_UPDATE_WAIT_MS = 3e3;
|
|
@@ -68,6 +68,7 @@ class PwaAutoUpdateController {
|
|
|
68
68
|
} catch {
|
|
69
69
|
this.log("registration.update() failed, reloading anyway");
|
|
70
70
|
}
|
|
71
|
+
this.activateWaitingWorker(registration);
|
|
71
72
|
}
|
|
72
73
|
await new Promise((resolve) => setTimeout(resolve, ERROR_UPDATE_WAIT_MS));
|
|
73
74
|
this.reload();
|
|
@@ -116,6 +117,19 @@ class PwaAutoUpdateController {
|
|
|
116
117
|
} catch (error) {
|
|
117
118
|
this.log("registration.update() failed:", error);
|
|
118
119
|
}
|
|
120
|
+
this.activateWaitingWorker(registration);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A freshly installed worker stays in `waiting` for as long as this page is
|
|
124
|
+
* controlled by the old one — a reload does not release it, only closing
|
|
125
|
+
* every tab on the origin does. Ask it to take over now; the generated
|
|
126
|
+
* worker answers this message with `skipWaiting()`, and the registration's
|
|
127
|
+
* own `activated` listener reloads the page onto the new build.
|
|
128
|
+
*/
|
|
129
|
+
activateWaitingWorker(registration) {
|
|
130
|
+
if (!registration.waiting) return;
|
|
131
|
+
this.log("New worker is waiting, sending SKIP_WAITING");
|
|
132
|
+
registration.waiting.postMessage({ type: "SKIP_WAITING" });
|
|
119
133
|
}
|
|
120
134
|
reload() {
|
|
121
135
|
if (this.isReloading) return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PwaAutoUpdateController.js","sources":["../../../../src/modules/pwa/PwaAutoUpdateController.ts"],"sourcesContent":["import type { PwaAutoUpdateOptions, PwaUpdateHandle } from './types'\n\nconst DEFAULT_IDLE_TIMEOUT_MS =
|
|
1
|
+
{"version":3,"file":"PwaAutoUpdateController.js","sources":["../../../../src/modules/pwa/PwaAutoUpdateController.ts"],"sourcesContent":["import type { PwaAutoUpdateOptions, PwaUpdateHandle } from './types'\n\nconst DEFAULT_IDLE_TIMEOUT_MS = 60 * 1000 // 1 minute\nconst DEFAULT_MIN_CHECK_INTERVAL_MS = 30 * 1000 // 30 seconds\nconst VISIBILITY_DEBOUNCE_MS = 1000\nconst ERROR_UPDATE_WAIT_MS = 3000\nconst IDLE_POLL_INTERVAL_MS = 10 * 1000 // check idle state every 10s\n\nconst USER_ACTIVITY_EVENTS = [\n 'mousemove',\n 'keydown',\n 'scroll',\n 'touchstart',\n 'click',\n] as const\n\n/**\n * Framework-agnostic controller that triggers service worker update checks\n * when the user is idle, switches tabs, or encounters errors.\n */\nexport class PwaAutoUpdateController {\n private handle: PwaUpdateHandle\n private options: Required<PwaAutoUpdateOptions>\n\n private lastCheckTimestamp = 0\n private lastActivityTimestamp = Date.now()\n private isReloading = false\n private visibilityDebounceTimer: ReturnType<typeof setTimeout> | undefined\n private idlePollTimer: ReturnType<typeof setInterval> | undefined\n\n private boundOnVisibilityChange = this.onVisibilityChange.bind(this)\n private boundOnUserActivity = this.onUserActivity.bind(this)\n\n constructor(handle: PwaUpdateHandle, options?: PwaAutoUpdateOptions) {\n this.handle = handle\n this.options = {\n idleTimeoutMs: options?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,\n minCheckIntervalMs:\n options?.minCheckIntervalMs ?? DEFAULT_MIN_CHECK_INTERVAL_MS,\n debug: options?.debug ?? false,\n }\n }\n\n start(): void {\n this.log('Starting auto-update controller')\n\n document.addEventListener('visibilitychange', this.boundOnVisibilityChange)\n\n for (const event of USER_ACTIVITY_EVENTS) {\n document.addEventListener(event, this.boundOnUserActivity, {\n passive: true,\n })\n }\n\n this.idlePollTimer = setInterval(() => {\n this.checkIdleState()\n }, IDLE_POLL_INTERVAL_MS)\n }\n\n destroy(): void {\n this.log('Destroying auto-update controller')\n\n document.removeEventListener(\n 'visibilitychange',\n this.boundOnVisibilityChange,\n )\n\n for (const event of USER_ACTIVITY_EVENTS) {\n document.removeEventListener(event, this.boundOnUserActivity)\n }\n\n if (this.visibilityDebounceTimer != null) {\n clearTimeout(this.visibilityDebounceTimer)\n this.visibilityDebounceTimer = undefined\n }\n\n if (this.idlePollTimer != null) {\n clearInterval(this.idlePollTimer)\n this.idlePollTimer = undefined\n }\n }\n\n /**\n * Called by the error boundary. Immediately checks for update,\n * waits briefly for a new SW, then reloads regardless.\n */\n async triggerUpdateOnError(): Promise<void> {\n this.log('Error-triggered update check')\n\n if (this.isReloading) return\n\n const { registration } = this.handle\n if (registration) {\n try {\n await registration.update()\n } catch {\n this.log('registration.update() failed, reloading anyway')\n }\n\n this.activateWaitingWorker(registration)\n }\n\n // Wait briefly for a new SW to activate, then reload\n await new Promise((resolve) => setTimeout(resolve, ERROR_UPDATE_WAIT_MS))\n this.reload()\n }\n\n /** Update the handle when registration becomes available async */\n updateHandle(handle: PwaUpdateHandle): void {\n this.handle = handle\n }\n\n private onVisibilityChange(): void {\n if (document.visibilityState !== 'visible') return\n\n // Debounce rapid tab switches\n if (this.visibilityDebounceTimer != null) {\n clearTimeout(this.visibilityDebounceTimer)\n }\n\n this.visibilityDebounceTimer = setTimeout(() => {\n this.visibilityDebounceTimer = undefined\n this.log('Tab became visible, checking for update')\n this.checkForUpdate()\n }, VISIBILITY_DEBOUNCE_MS)\n }\n\n private onUserActivity(): void {\n this.lastActivityTimestamp = Date.now()\n }\n\n private checkIdleState(): void {\n const idleTime = Date.now() - this.lastActivityTimestamp\n if (idleTime >= this.options.idleTimeoutMs) {\n this.log(`Idle for ${Math.round(idleTime / 1000)}s, checking for update`)\n this.checkForUpdate()\n }\n }\n\n private async checkForUpdate(): Promise<void> {\n if (this.isReloading) return\n\n const now = Date.now()\n if (now - this.lastCheckTimestamp < this.options.minCheckIntervalMs) {\n this.log('Skipping check — too soon since last check')\n return\n }\n\n const { registration } = this.handle\n if (!registration) {\n this.log('No SW registration available, skipping check')\n return\n }\n\n this.lastCheckTimestamp = now\n this.log('Calling registration.update()')\n\n try {\n await registration.update()\n } catch (error) {\n this.log('registration.update() failed:', error)\n }\n\n this.activateWaitingWorker(registration)\n }\n\n /**\n * A freshly installed worker stays in `waiting` for as long as this page is\n * controlled by the old one — a reload does not release it, only closing\n * every tab on the origin does. Ask it to take over now; the generated\n * worker answers this message with `skipWaiting()`, and the registration's\n * own `activated` listener reloads the page onto the new build.\n */\n private activateWaitingWorker(registration: ServiceWorkerRegistration): void {\n if (!registration.waiting) return\n\n this.log('New worker is waiting, sending SKIP_WAITING')\n registration.waiting.postMessage({ type: 'SKIP_WAITING' })\n }\n\n private reload(): void {\n if (this.isReloading) return\n this.isReloading = true\n this.log('Reloading page')\n this.handle.updateSW(true)\n }\n\n private log(...args: unknown[]): void {\n if (this.options.debug) {\n console.log('[PWA Auto-Update]', ...args)\n }\n }\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,30 @@
|
|
|
1
|
+
import { ComponentType } from 'react';
|
|
1
2
|
import { PwaAutoUpdateOptions } from '~/modules/pwa/types';
|
|
3
|
+
/** Icon component for an area header. Lucide icon components satisfy this. */
|
|
4
|
+
export type AreaIcon = ComponentType<{
|
|
5
|
+
size?: number;
|
|
6
|
+
strokeWidth?: number;
|
|
7
|
+
className?: string;
|
|
8
|
+
}>;
|
|
9
|
+
/**
|
|
10
|
+
* Presentation of the area sections on the home view. The taxonomy is
|
|
11
|
+
* deployment-specific, so the core ships none of it: labels come from the
|
|
12
|
+
* catalog's own `category` tag definition and everything below is optional.
|
|
13
|
+
*/
|
|
14
|
+
export interface AreasSettings {
|
|
15
|
+
/**
|
|
16
|
+
* Icon per `category:<value>`; the key `DAY_TO_DAY_AREA_KEY` targets the
|
|
17
|
+
* merged first group. Areas without an icon render title-only.
|
|
18
|
+
*/
|
|
19
|
+
icons?: Record<string, AreaIcon>;
|
|
20
|
+
/**
|
|
21
|
+
* Category values that fold into the day-to-day group instead of getting an
|
|
22
|
+
* area of their own (e.g. perks, office services).
|
|
23
|
+
*/
|
|
24
|
+
dayToDayCategories?: string[];
|
|
25
|
+
/** Title of the merged first group. Defaults to "Day-to-day tools". */
|
|
26
|
+
dayToDayLabel?: string;
|
|
27
|
+
}
|
|
2
28
|
/**
|
|
3
29
|
* UI settings passed from the app entry point.
|
|
4
30
|
*/
|
|
@@ -10,6 +36,8 @@ export interface UiSettings {
|
|
|
10
36
|
};
|
|
11
37
|
/** Frontend build identifier baked at build time (e.g., pipeline ID via VITE env var) */
|
|
12
38
|
frontendBuildId?: string;
|
|
39
|
+
/** Area sections on the home view: icons and the day-to-day fold. */
|
|
40
|
+
areas?: AreasSettings;
|
|
13
41
|
/** PWA auto-update configuration (idle timeout, check interval, debug) */
|
|
14
42
|
pwaAutoUpdate?: PwaAutoUpdateOptions;
|
|
15
43
|
/**
|
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-alpha-20260917212201",
|
|
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-alpha-20260917212201"
|
|
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-alpha-20260917212201"
|
|
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": "aabe7732c6f3ac7e928420c17e51adab0ece16ca",
|
|
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
|
/>
|