@kennofizet/apphub-frontend 0.1.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 (90) hide show
  1. package/README.md +84 -0
  2. package/package.json +31 -0
  3. package/src/api/coreApi.js +25 -0
  4. package/src/api/index.js +80 -0
  5. package/src/composables/createZoneContext.js +156 -0
  6. package/src/composables/useAppHubHostApi.js +24 -0
  7. package/src/composables/useAppHubZoneContext.js +11 -0
  8. package/src/composables/useDevOriginToggle.js +40 -0
  9. package/src/i18n/index.js +16 -0
  10. package/src/i18n/resolveLang.js +6 -0
  11. package/src/i18n/resolveTheme.js +30 -0
  12. package/src/i18n/translations/en.js +303 -0
  13. package/src/i18n/translations/vi.js +302 -0
  14. package/src/index.js +427 -0
  15. package/src/moduleStore.js +10 -0
  16. package/src/modules/app-store/components/AppHubAppStoreApp.vue +210 -0
  17. package/src/modules/app-store/components/AppHubAppStoreCard.vue +88 -0
  18. package/src/modules/app-store/components/AppHubAppStoreSettingsPanel.vue +266 -0
  19. package/src/modules/app-store/components/AppHubAppVersionHistory.vue +77 -0
  20. package/src/modules/app-store/components/AppHubDevReviewPanel.vue +206 -0
  21. package/src/modules/app-store/components/AppHubDraftStoreApp.vue +184 -0
  22. package/src/modules/app-store/components/AppHubDraftStoreCard.vue +116 -0
  23. package/src/modules/app-store/composables/useAppStore.js +206 -0
  24. package/src/modules/app-store/composables/useCatalogInfiniteScroll.js +47 -0
  25. package/src/modules/app-store/constants/catalogModes.js +2 -0
  26. package/src/modules/app-store/data/defaultCatalog.js +19 -0
  27. package/src/modules/app-store/index.js +9 -0
  28. package/src/modules/app-store/utils/normalizeCatalogApp.js +37 -0
  29. package/src/modules/desktop/components/AppHubDesktop.vue +1510 -0
  30. package/src/modules/desktop/components/AppHubDesktopDevOriginBar.vue +57 -0
  31. package/src/modules/desktop/components/AppHubDesktopDropLayer.vue +15 -0
  32. package/src/modules/desktop/components/AppHubDesktopDropTarget.vue +32 -0
  33. package/src/modules/desktop/components/AppHubDesktopIconContextMenu.vue +74 -0
  34. package/src/modules/desktop/components/AppHubDesktopIconFolder.vue +60 -0
  35. package/src/modules/desktop/components/AppHubDesktopIconGroup.vue +58 -0
  36. package/src/modules/desktop/components/AppHubDesktopIconInfoDialog.vue +33 -0
  37. package/src/modules/desktop/components/AppHubDesktopIconRenameDialog.vue +62 -0
  38. package/src/modules/desktop/components/AppHubDesktopSettings.vue +28 -0
  39. package/src/modules/desktop/components/AppHubDropInstallBadge.vue +65 -0
  40. package/src/modules/desktop/components/AppHubDuplicateAppDialog.vue +38 -0
  41. package/src/modules/desktop/components/AppHubGuideApp.vue +278 -0
  42. package/src/modules/desktop/components/AppHubOriginBlockScreen.vue +105 -0
  43. package/src/modules/desktop/components/AppHubOriginLoadingScreen.vue +23 -0
  44. package/src/modules/desktop/components/AppHubPlaceholderApp.vue +14 -0
  45. package/src/modules/desktop/components/AppHubSettingsApp.vue +319 -0
  46. package/src/modules/desktop/components/AppHubStartButton.vue +24 -0
  47. package/src/modules/desktop/components/AppHubStartMenu.vue +182 -0
  48. package/src/modules/desktop/components/AppHubTaskbarPins.vue +23 -0
  49. package/src/modules/desktop/components/settings/AppHubSettingsKeyboardPanel.vue +82 -0
  50. package/src/modules/desktop/components/settings/AppHubSettingsScreenPanel.vue +41 -0
  51. package/src/modules/desktop/components/settings/AppHubSettingsStartMenuPanel.vue +95 -0
  52. package/src/modules/desktop/composables/simulateInstallProgress.js +15 -0
  53. package/src/modules/desktop/composables/useDesktopDropInstall.js +272 -0
  54. package/src/modules/desktop/composables/useDesktopHubSettings.js +51 -0
  55. package/src/modules/desktop/composables/useDesktopIconDrag.js +207 -0
  56. package/src/modules/desktop/composables/useDesktopShell.js +335 -0
  57. package/src/modules/desktop/data/builtinApps.js +77 -0
  58. package/src/modules/desktop/index.js +12 -0
  59. package/src/modules/desktop/styles/desktop.css +3104 -0
  60. package/src/modules/desktop/styles/theme.css +616 -0
  61. package/src/modules/desktop/utils/desktopGrid.js +43 -0
  62. package/src/modules/desktop/utils/desktopIconGroups.js +103 -0
  63. package/src/modules/desktop/utils/desktopSession.js +40 -0
  64. package/src/modules/desktop/utils/desktopSettings.js +37 -0
  65. package/src/modules/desktop/utils/dropPackageParser.js +140 -0
  66. package/src/modules/desktop/utils/duplicateAppUtils.js +28 -0
  67. package/src/modules/desktop/utils/hubKeyboardSettings.js +63 -0
  68. package/src/modules/desktop/utils/recentApps.js +148 -0
  69. package/src/modules/desktop/utils/startMenuFavorites.js +100 -0
  70. package/src/modules/desktop/utils/startMenuPins.js +90 -0
  71. package/src/modules/notifications/components/AppHubDesktopNotifications.vue +54 -0
  72. package/src/modules/notifications/composables/createDesktopNotifications.js +86 -0
  73. package/src/modules/notifications/index.js +9 -0
  74. package/src/modules/notifications/styles/notifications.css +118 -0
  75. package/src/modules/notifications/utils/parseApiError.js +29 -0
  76. package/src/modules/runner/components/AppHubRunner.vue +292 -0
  77. package/src/modules/runner/index.js +1 -0
  78. package/src/modules/window-manager/components/AppHubWindowFrame.vue +224 -0
  79. package/src/modules/window-manager/composables/useWindowManager.js +652 -0
  80. package/src/modules/window-manager/index.js +7 -0
  81. package/src/modules/window-manager/utils/sessionLayout.js +28 -0
  82. package/src/modules/window-manager/utils/windowLayout.js +236 -0
  83. package/src/modules/window-manager/utils/windowSnap.js +146 -0
  84. package/src/utils/bootstrapCache.js +47 -0
  85. package/src/utils/devOriginSettings.js +22 -0
  86. package/src/utils/launchUrl.js +111 -0
  87. package/src/utils/originSafety.js +267 -0
  88. package/src/utils/safeStorage.js +191 -0
  89. package/src/utils/semver.js +30 -0
  90. package/src/utils/zoneContext.js +38 -0
@@ -0,0 +1,184 @@
1
+ <template>
2
+ <div class="apphub-draft-store">
3
+ <header class="apphub-draft-store__hero">
4
+ <span class="apphub-draft-store__hero-icon" aria-hidden="true">🧪</span>
5
+ <div class="apphub-draft-store__hero-text">
6
+ <h2 class="apphub-draft-store__title">{{ labels.draft_store_title }}</h2>
7
+ <p class="apphub-draft-store__intro">{{ labels.draft_store_intro }}</p>
8
+ </div>
9
+ </header>
10
+
11
+ <div ref="scrollRoot" class="apphub-draft-store__body apphub-draft-store__body--scroll">
12
+ <input
13
+ v-model="catalog.search"
14
+ type="search"
15
+ class="apphub-draft-store__search"
16
+ :placeholder="labels.app_store_search"
17
+ />
18
+
19
+ <p v-if="catalog.loading && !catalog.items.length" class="apphub-draft-store__msg">
20
+ {{ labels.app_store_loading }}
21
+ </p>
22
+ <p v-else-if="catalog.error === 'permission_denied'" class="apphub-draft-store__msg apphub-draft-store__msg--error">
23
+ {{ labels.app_store_permission_denied }}
24
+ </p>
25
+ <p v-else-if="catalog.error === 'load_failed'" class="apphub-draft-store__msg apphub-draft-store__msg--error">
26
+ {{ labels.app_store_load_error }}
27
+ </p>
28
+ <p v-else-if="catalog.error === 'no_api'" class="apphub-draft-store__msg apphub-draft-store__msg--warn">
29
+ {{ labels.app_store_no_api }}
30
+ </p>
31
+
32
+ <div
33
+ v-else-if="!catalog.loading && !appStore.filteredTestingApps.length"
34
+ class="apphub-draft-store__empty"
35
+ >
36
+ <span class="apphub-draft-store__empty-icon" aria-hidden="true">📭</span>
37
+ <p>{{ labels.draft_store_empty }}</p>
38
+ </div>
39
+
40
+ <template v-else-if="appStore.filteredTestingApps.length">
41
+ <ul class="apphub-draft-store__list">
42
+ <li v-for="app in appStore.filteredTestingApps" :key="app.slug">
43
+ <AppHubDraftStoreCard
44
+ :app="app"
45
+ :labels="labels"
46
+ :root-app="rootApp"
47
+ :installed="appStore.isInstalled(app.slug)"
48
+ :can-install="appStore.canInstall(app)"
49
+ :pinging="pingingSlug === app.slug"
50
+ :ping-result="pingResults[app.slug] ?? null"
51
+ @install="onInstall"
52
+ @uninstall="onUninstall"
53
+ @ping="onPing"
54
+ />
55
+ </li>
56
+ </ul>
57
+ <p v-if="catalog.loadingMore" class="apphub-catalog-footer">{{ labels.app_store_loading_more }}</p>
58
+ <div ref="scrollSentinel" class="apphub-catalog-sentinel" aria-hidden="true" />
59
+ </template>
60
+ </div>
61
+ </div>
62
+ </template>
63
+
64
+ <script setup>
65
+ import { computed, getCurrentInstance, inject, onMounted, reactive, ref, watch } from 'vue'
66
+ import {
67
+ getHostApiForApp,
68
+ isBackendReadyForApp,
69
+ resolveRootApp,
70
+ } from '../../../composables/useAppHubHostApi.js'
71
+ import { useAppHubZoneContext } from '../../../composables/useAppHubZoneContext.js'
72
+ import { t } from '../../../i18n/index.js'
73
+ import { resolveLang } from '../../../i18n/resolveLang.js'
74
+ import { CATALOG_MODE_DRAFT } from '../constants/catalogModes.js'
75
+ import { useCatalogInfiniteScroll } from '../composables/useCatalogInfiniteScroll.js'
76
+ import { useAppStore } from '../composables/useAppStore.js'
77
+ import AppHubDraftStoreCard from './AppHubDraftStoreCard.vue'
78
+
79
+ const props = defineProps({
80
+ onInstalled: { type: Function, default: null },
81
+ onUninstalled: { type: Function, default: null },
82
+ })
83
+
84
+ const appStore = useAppStore()
85
+ const catalog = appStore.catalogs.draft
86
+ const rootApp = resolveRootApp(getCurrentInstance())
87
+ const zone = useAppHubZoneContext()
88
+ const moduleOptions = inject('apphubOptions', {})
89
+ const lang = computed(() => resolveLang(moduleOptions?.language, 'vi'))
90
+ const pingingSlug = ref('')
91
+ const pingResults = reactive({})
92
+
93
+ const labels = computed(() => ({
94
+ draft_store_title: t('draft_store_title', lang.value),
95
+ draft_store_intro: t('draft_store_intro', lang.value),
96
+ draft_store_empty: t('draft_store_empty', lang.value),
97
+ app_store_search: t('app_store_search', lang.value),
98
+ app_store_install: t('app_store_install', lang.value),
99
+ app_store_loading: t('app_store_loading', lang.value),
100
+ app_store_loading_more: t('app_store_loading_more', lang.value),
101
+ app_store_load_error: t('app_store_load_error', lang.value),
102
+ app_store_permission_denied: t('app_store_permission_denied', lang.value),
103
+ app_store_no_api: t('app_store_no_api', lang.value),
104
+ app_store_unavailable: t('app_store_unavailable', lang.value),
105
+ app_store_status_draft: t('app_store_status_draft', lang.value),
106
+ app_store_installed: t('app_store_installed', lang.value),
107
+ app_store_uninstall: t('app_store_uninstall', lang.value),
108
+ draft_ping_btn: t('draft_ping_btn', lang.value),
109
+ draft_ping_pinging: t('draft_ping_pinging', lang.value),
110
+ draft_ping_ok: t('draft_ping_ok', lang.value),
111
+ draft_ping_fail: t('draft_ping_fail', lang.value),
112
+ dev_review_history_btn: t('dev_review_history_btn', lang.value),
113
+ dev_review_history_loading: t('dev_review_history_loading', lang.value),
114
+ dev_review_history_title: t('dev_review_history_title', lang.value),
115
+ dev_review_history_empty: t('dev_review_history_empty', lang.value),
116
+ dev_review_history_current: t('dev_review_history_current', lang.value),
117
+ dev_review_history_error: t('dev_review_history_error', lang.value),
118
+ }))
119
+
120
+ function hostApiOptions() {
121
+ return {
122
+ backendReady: isBackendReadyForApp(rootApp),
123
+ mode: CATALOG_MODE_DRAFT,
124
+ }
125
+ }
126
+
127
+ async function reloadCatalog() {
128
+ if (!rootApp) return
129
+ await appStore.loadCatalog(getHostApiForApp(rootApp), hostApiOptions())
130
+ }
131
+
132
+ async function loadMore() {
133
+ if (!rootApp) return
134
+ await appStore.loadMoreCatalog(getHostApiForApp(rootApp), CATALOG_MODE_DRAFT, hostApiOptions())
135
+ }
136
+
137
+ const { rootRef: scrollRoot, sentinelRef: scrollSentinel } = useCatalogInfiniteScroll({
138
+ canLoadMore: () => catalog.hasMore && !catalog.loading && !catalog.loadingMore,
139
+ onLoadMore: loadMore,
140
+ })
141
+
142
+ async function onInstall(app) {
143
+ if (!appStore.installApp(app.slug)) return
144
+ await props.onInstalled?.(app)
145
+ }
146
+
147
+ async function onUninstall(app) {
148
+ if (!appStore.uninstallApp(app.slug)) return
149
+ await props.onUninstalled?.(app)
150
+ }
151
+
152
+ async function onPing(app) {
153
+ const api = getHostApiForApp(rootApp)
154
+ if (!api?.ping || !app?.slug) return
155
+ pingingSlug.value = app.slug
156
+ delete pingResults[app.slug]
157
+ try {
158
+ const res = await api.ping(app.slug)
159
+ pingResults[app.slug] = res?.data?.data ?? { ok: false }
160
+ } catch {
161
+ pingResults[app.slug] = { ok: false }
162
+ } finally {
163
+ pingingSlug.value = ''
164
+ }
165
+ }
166
+
167
+ onMounted(() => {
168
+ if (!catalog.loaded) reloadCatalog()
169
+ })
170
+
171
+ watch(
172
+ () => moduleOptions?.hasToken,
173
+ (hasToken) => {
174
+ if (hasToken && !catalog.loaded) reloadCatalog()
175
+ },
176
+ )
177
+
178
+ watch(
179
+ () => [zone?.state?.selectedZoneId, zone?.state?.viewAllZones],
180
+ () => {
181
+ if (moduleOptions?.hasToken) reloadCatalog()
182
+ },
183
+ )
184
+ </script>
@@ -0,0 +1,116 @@
1
+ <template>
2
+ <article class="apphub-draft-card">
3
+ <div class="apphub-draft-card__main">
4
+ <div class="apphub-draft-card__icon" aria-hidden="true">{{ app.icon }}</div>
5
+ <div class="apphub-draft-card__body">
6
+ <h3 class="apphub-draft-card__name">{{ app.name }}</h3>
7
+ <p class="apphub-draft-card__slug">
8
+ {{ app.slug }}<span v-if="app.version"> · v{{ app.version }}</span>
9
+ </p>
10
+ <p v-if="app.description" class="apphub-draft-card__desc">{{ app.description }}</p>
11
+ </div>
12
+ </div>
13
+
14
+ <div class="apphub-draft-card__actions">
15
+ <button
16
+ v-if="!installed && canInstall"
17
+ type="button"
18
+ class="apphub-draft-card__btn apphub-draft-card__btn--primary"
19
+ @click="emit('install', app)"
20
+ >
21
+ {{ labels.app_store_install }}
22
+ </button>
23
+ <span
24
+ v-else-if="!canInstall"
25
+ class="apphub-draft-card__unavailable"
26
+ >
27
+ {{ labels.app_store_unavailable }}
28
+ </span>
29
+ <template v-else>
30
+ <span class="apphub-draft-card__installed" :title="labels.app_store_installed">
31
+ <span class="apphub-draft-card__installed-check" aria-hidden="true">✓</span>
32
+ {{ labels.app_store_installed }}
33
+ </span>
34
+ <button
35
+ type="button"
36
+ class="apphub-draft-card__btn apphub-draft-card__btn--secondary"
37
+ @click="emit('uninstall', app)"
38
+ >
39
+ {{ labels.app_store_uninstall }}
40
+ </button>
41
+ </template>
42
+
43
+ <button
44
+ v-if="app.runtime_type === 'hosted'"
45
+ type="button"
46
+ class="apphub-draft-card__btn apphub-draft-card__btn--secondary"
47
+ @click="historyOpen = !historyOpen"
48
+ >
49
+ {{ labels.dev_review_history_btn }}
50
+ </button>
51
+
52
+ <button
53
+ v-if="app.healthcheck_url"
54
+ type="button"
55
+ class="apphub-draft-card__btn apphub-draft-card__btn--secondary"
56
+ :disabled="pinging"
57
+ @click="emit('ping', app)"
58
+ >
59
+ {{ pinging ? labels.draft_ping_pinging : labels.draft_ping_btn }}
60
+ </button>
61
+ </div>
62
+
63
+ <AppHubAppVersionHistory
64
+ v-if="app.runtime_type === 'hosted'"
65
+ :slug="app.slug"
66
+ :root-app="rootApp"
67
+ :open="historyOpen"
68
+ :labels="historyLabels"
69
+ />
70
+
71
+ <p
72
+ v-if="pingResult"
73
+ class="apphub-draft-card__ping"
74
+ :class="pingResult.ok ? 'apphub-draft-card__ping--ok' : 'apphub-draft-card__ping--bad'"
75
+ >
76
+ {{ pingLabel }}
77
+ </p>
78
+ </article>
79
+ </template>
80
+
81
+ <script setup>
82
+ import { computed, ref } from 'vue'
83
+ import AppHubAppVersionHistory from './AppHubAppVersionHistory.vue'
84
+
85
+ const props = defineProps({
86
+ app: { type: Object, required: true },
87
+ labels: { type: Object, required: true },
88
+ rootApp: { type: Object, default: null },
89
+ installed: { type: Boolean, default: false },
90
+ canInstall: { type: Boolean, default: true },
91
+ pinging: { type: Boolean, default: false },
92
+ pingResult: { type: Object, default: null },
93
+ })
94
+
95
+ const historyOpen = ref(false)
96
+
97
+ const historyLabels = computed(() => ({
98
+ title: props.labels.dev_review_history_title,
99
+ loading: props.labels.dev_review_history_loading,
100
+ empty: props.labels.dev_review_history_empty,
101
+ current: props.labels.dev_review_history_current,
102
+ no_api: props.labels.app_store_no_api,
103
+ load_error: props.labels.dev_review_history_error,
104
+ }))
105
+
106
+ const emit = defineEmits(['install', 'uninstall', 'ping'])
107
+
108
+ const pingLabel = computed(() => {
109
+ if (!props.pingResult) return ''
110
+ if (props.pingResult.ok) {
111
+ const ms = props.pingResult.latency_ms != null ? ` · ${props.pingResult.latency_ms} ms` : ''
112
+ return `${props.labels.draft_ping_ok}${ms}`
113
+ }
114
+ return props.labels.draft_ping_fail
115
+ })
116
+ </script>
@@ -0,0 +1,206 @@
1
+ import { computed, inject, reactive } from 'vue'
2
+ import { CATALOG_MODE_DRAFT, CATALOG_MODE_STORE } from '../constants/catalogModes.js'
3
+ import { normalizeCatalogList } from '../utils/normalizeCatalogApp.js'
4
+
5
+ const APP_STORE_KEY = 'apphubAppStore'
6
+
7
+ function createCatalogBucket() {
8
+ return {
9
+ items: [],
10
+ search: '',
11
+ loading: false,
12
+ loadingMore: false,
13
+ error: '',
14
+ loaded: false,
15
+ nextCursor: null,
16
+ hasMore: false,
17
+ }
18
+ }
19
+
20
+ function filterItems(items, search) {
21
+ const q = search.trim().toLowerCase()
22
+ if (!q) return items
23
+ return items.filter(
24
+ (app) =>
25
+ app.name.toLowerCase().includes(q) ||
26
+ app.slug.toLowerCase().includes(q) ||
27
+ (app.description || '').toLowerCase().includes(q),
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Independent App Store module — separate catalog buckets per mode (store / draft).
33
+ */
34
+ export function createAppStoreState(options = {}) {
35
+ const catalogs = reactive({
36
+ store: createCatalogBucket(),
37
+ draft: createCatalogBucket(),
38
+ })
39
+
40
+ const state = reactive({
41
+ installedSlugs: options.installedSlugs ?? [],
42
+ })
43
+
44
+ const filteredStoreApps = computed(() =>
45
+ filterItems(catalogs.store.items, catalogs.store.search),
46
+ )
47
+
48
+ const filteredTestingApps = computed(() =>
49
+ filterItems(catalogs.draft.items, catalogs.draft.search),
50
+ )
51
+
52
+ function bucketFor(mode) {
53
+ return mode === CATALOG_MODE_DRAFT ? catalogs.draft : catalogs.store
54
+ }
55
+
56
+ function findCatalogItem(slug) {
57
+ return (
58
+ catalogs.store.items.find((a) => a.slug === slug)
59
+ ?? catalogs.draft.items.find((a) => a.slug === slug)
60
+ ?? null
61
+ )
62
+ }
63
+
64
+ function isInstalled(slug) {
65
+ return state.installedSlugs.includes(slug)
66
+ }
67
+
68
+ function canInstall(app) {
69
+ return app?.status !== 'disabled'
70
+ }
71
+
72
+ function installApp(slug) {
73
+ if (isInstalled(slug)) return false
74
+ const item = findCatalogItem(slug)
75
+ if (item && !canInstall(item)) return false
76
+ state.installedSlugs.push(slug)
77
+ if (item) item.installed = true
78
+ return true
79
+ }
80
+
81
+ function uninstallApp(slug) {
82
+ const normalized = String(slug ?? '').trim()
83
+ if (!normalized) return false
84
+ const idx = state.installedSlugs.indexOf(normalized)
85
+ if (idx === -1) return false
86
+ state.installedSlugs.splice(idx, 1)
87
+ syncInstalledFlags()
88
+ return true
89
+ }
90
+
91
+ function syncInstalledFlags() {
92
+ for (const bucket of Object.values(catalogs)) {
93
+ for (const app of bucket.items) {
94
+ app.installed = state.installedSlugs.includes(app.slug)
95
+ }
96
+ }
97
+ }
98
+
99
+ async function loadCatalog(hostApi, options = {}) {
100
+ const mode = options.mode === CATALOG_MODE_DRAFT ? CATALOG_MODE_DRAFT : CATALOG_MODE_STORE
101
+ const bucket = bucketFor(mode)
102
+ const append = options.append === true
103
+ const backendReady = options.backendReady !== false
104
+
105
+ if (!backendReady || !hostApi?.apps) {
106
+ if (!append) {
107
+ bucket.items = []
108
+ bucket.error = 'no_api'
109
+ bucket.loaded = false
110
+ bucket.nextCursor = null
111
+ bucket.hasMore = false
112
+ }
113
+ return
114
+ }
115
+
116
+ if (append) {
117
+ if (!bucket.hasMore || bucket.loadingMore || bucket.loading) return
118
+ bucket.loadingMore = true
119
+ } else {
120
+ bucket.loading = true
121
+ bucket.error = ''
122
+ }
123
+
124
+ try {
125
+ const params = {
126
+ mode,
127
+ per_page: options.perPage ?? 24,
128
+ }
129
+ if (append && bucket.nextCursor) {
130
+ params.cursor = bucket.nextCursor
131
+ }
132
+
133
+ const res = await hostApi.apps(params)
134
+ if (res === undefined || res === null) {
135
+ if (!append) {
136
+ bucket.items = []
137
+ bucket.error = 'no_api'
138
+ bucket.loaded = false
139
+ }
140
+ return
141
+ }
142
+
143
+ const rows = normalizeCatalogList(res?.data?.data ?? res?.data?.datas ?? [])
144
+ const meta = res?.data?.meta ?? {}
145
+
146
+ if (append) {
147
+ const existing = new Set(bucket.items.map((a) => a.slug))
148
+ bucket.items.push(...rows.filter((a) => !existing.has(a.slug)))
149
+ } else {
150
+ bucket.items = rows
151
+ }
152
+
153
+ bucket.nextCursor = meta.next_cursor ?? null
154
+ bucket.hasMore = meta.has_more === true
155
+ syncInstalledFlags()
156
+ bucket.loaded = true
157
+ } catch (err) {
158
+ if (!append) {
159
+ const status = err?.response?.status
160
+ bucket.items = []
161
+ bucket.error = status === 403 ? 'permission_denied' : 'load_failed'
162
+ bucket.loaded = true
163
+ bucket.nextCursor = null
164
+ bucket.hasMore = false
165
+ }
166
+ } finally {
167
+ if (append) {
168
+ bucket.loadingMore = false
169
+ } else {
170
+ bucket.loading = false
171
+ }
172
+ }
173
+ }
174
+
175
+ async function loadMoreCatalog(hostApi, mode, options = {}) {
176
+ const bucket = bucketFor(mode)
177
+ if (!bucket.hasMore || bucket.loadingMore || bucket.loading) return
178
+ await loadCatalog(hostApi, { ...options, mode, append: true })
179
+ }
180
+
181
+ return reactive({
182
+ state,
183
+ catalogs,
184
+ filteredStoreApps,
185
+ filteredTestingApps,
186
+ findCatalogItem,
187
+ isInstalled,
188
+ canInstall,
189
+ installApp,
190
+ uninstallApp,
191
+ loadCatalog,
192
+ loadMoreCatalog,
193
+ })
194
+ }
195
+
196
+ export function provideAppStore(app, store) {
197
+ app.provide(APP_STORE_KEY, store)
198
+ }
199
+
200
+ export function useAppStore() {
201
+ const store = inject(APP_STORE_KEY, null)
202
+ if (!store) {
203
+ throw new Error('useAppStore() requires provideAppStore()')
204
+ }
205
+ return store
206
+ }
@@ -0,0 +1,47 @@
1
+ import { onMounted, onUnmounted, ref, watch } from 'vue'
2
+
3
+ /**
4
+ * Load next catalog page when the scroll root nears the bottom.
5
+ */
6
+ export function useCatalogInfiniteScroll(options) {
7
+ const rootRef = ref(null)
8
+ const sentinelRef = ref(null)
9
+ let observer = null
10
+
11
+ function disconnect() {
12
+ observer?.disconnect()
13
+ observer = null
14
+ }
15
+
16
+ function connect() {
17
+ disconnect()
18
+ const root = rootRef.value
19
+ const sentinel = sentinelRef.value
20
+ if (!root || !sentinel || !options.canLoadMore?.()) return
21
+
22
+ observer = new IntersectionObserver(
23
+ (entries) => {
24
+ if (entries.some((e) => e.isIntersecting)) {
25
+ options.onLoadMore?.()
26
+ }
27
+ },
28
+ { root, rootMargin: '120px', threshold: 0 },
29
+ )
30
+ observer.observe(sentinel)
31
+ }
32
+
33
+ onMounted(() => {
34
+ connect()
35
+ })
36
+
37
+ onUnmounted(() => {
38
+ disconnect()
39
+ })
40
+
41
+ watch(
42
+ () => [options.canLoadMore?.(), rootRef.value, sentinelRef.value],
43
+ () => connect(),
44
+ )
45
+
46
+ return { rootRef, sentinelRef, reconnect: connect }
47
+ }
@@ -0,0 +1,2 @@
1
+ export const CATALOG_MODE_STORE = 'store'
2
+ export const CATALOG_MODE_DRAFT = 'draft'
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Legacy demo catalog — drop-install fallback only. App Store loads from GET /apps.
3
+ */
4
+ export const defaultAppStoreCatalog = [
5
+ {
6
+ slug: 'demo-notes',
7
+ name: 'Notes',
8
+ description: 'Simple notes app (demo).',
9
+ icon: '📝',
10
+ installed: false,
11
+ },
12
+ {
13
+ slug: 'demo-gallery',
14
+ name: 'Gallery',
15
+ description: 'View images and video (demo).',
16
+ icon: '🖼️',
17
+ installed: false,
18
+ },
19
+ ]
@@ -0,0 +1,9 @@
1
+ export { defaultAppStoreCatalog } from './data/defaultCatalog.js'
2
+ export { normalizeCatalogApp, normalizeCatalogList } from './utils/normalizeCatalogApp.js'
3
+ export {
4
+ createAppStoreState,
5
+ provideAppStore,
6
+ useAppStore,
7
+ } from './composables/useAppStore.js'
8
+ export { default as AppHubAppStoreApp } from './components/AppHubAppStoreApp.vue'
9
+ export { default as AppHubDraftStoreApp } from './components/AppHubDraftStoreApp.vue'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Map apphub-backend catalog row to App Store card shape.
3
+ * @param {unknown} row
4
+ * @returns {object|null}
5
+ */
6
+ export function normalizeCatalogApp(row) {
7
+ if (!row || typeof row !== 'object') return null
8
+ const slug = typeof row.slug === 'string' ? row.slug.trim() : ''
9
+ if (!slug) return null
10
+
11
+ const entryUrl = typeof row.entry_url === 'string' ? row.entry_url.trim() : ''
12
+ const healthcheckUrl = typeof row.healthcheck_url === 'string' ? row.healthcheck_url.trim() : ''
13
+
14
+ return {
15
+ slug,
16
+ version: typeof row.version === 'string' && row.version.trim() ? row.version.trim() : null,
17
+ name: typeof row.name === 'string' && row.name.trim() ? row.name.trim() : slug,
18
+ description: typeof row.description === 'string' ? row.description : '',
19
+ icon: typeof row.icon === 'string' && row.icon ? row.icon : '📦',
20
+ status: typeof row.status === 'string' ? row.status : 'active',
21
+ runtime_type: typeof row.runtime_type === 'string' ? row.runtime_type : 'iframe',
22
+ entry_url: entryUrl || null,
23
+ healthcheck_url: healthcheckUrl || null,
24
+ bundle_hash: typeof row.bundle_hash === 'string' ? row.bundle_hash : null,
25
+ bundle_entry: typeof row.bundle_entry === 'string' ? row.bundle_entry : null,
26
+ bundle_file_count: Number.isFinite(Number(row.bundle_file_count))
27
+ ? Number(row.bundle_file_count)
28
+ : null,
29
+ installed: !!row.installed,
30
+ }
31
+ }
32
+
33
+ /** @param {unknown} payload */
34
+ export function normalizeCatalogList(payload) {
35
+ const rows = Array.isArray(payload) ? payload : []
36
+ return rows.map(normalizeCatalogApp).filter(Boolean)
37
+ }