- Mobile launches use the store-driven panel (no route push) so the background tab no longer changes and closing returns to where you launched from. - Tab-only apps open directly (in-app WebView on companion / new tab on PWA) — no "this app opens in a tab" interstitial. - Shared AppLoadingScreen (app icon + progress bar) on the app session and the legacy iframe overlay instead of a black screen. - Pin the dashboard to 100dvh on mobile so the mesh chat/tools panes stop sliding under the bottom tab bar in mobile browsers (no-op in the companion WebView). - ElectrumX/electrs/electrs-ui ids now resolve to the real ElectrumX icon in My Apps. - isMobile made reactive so overlay/footer/teleport decisions track the viewport. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
424 lines
20 KiB
TypeScript
424 lines
20 KiB
TypeScript
/** Static configuration for the Apps view */
|
|
|
|
import type { Ref } from 'vue'
|
|
import { computed } from 'vue'
|
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
|
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
|
|
|
export type AppsTab = 'apps' | 'websites' | 'services'
|
|
|
|
// Service container name patterns (backend/infra, not user-facing)
|
|
export const SERVICE_NAMES = new Set([
|
|
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
|
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
|
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
|
// built-in Mesh tab) belong in Services, not My Apps.
|
|
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
|
|
'immich_postgres', 'immich_redis',
|
|
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
|
|
// the launcher app; postgres/redis are backends → Services.
|
|
'immich-postgres', 'immich-redis',
|
|
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
|
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
|
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
|
|
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
|
'indeedhub-api', 'indeedhub-ffmpeg',
|
|
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
|
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
|
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
|
// L484 web-only apps — parked in Services for now
|
|
'nwnn', '484-kitchen', 'call-the-operator',
|
|
'syntropy-institute', 't-zero', 'arch-presentation',
|
|
])
|
|
|
|
const INTERNAL_TOOLING_NAMES = new Set([
|
|
'buildx_buildkit_default',
|
|
])
|
|
|
|
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
|
|
const manifestId = pkg?.manifest?.id || ''
|
|
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
|
|
}
|
|
|
|
export function isServiceContainer(id: string): boolean {
|
|
if (SERVICE_NAMES.has(id)) return true
|
|
if (id.startsWith('indeedhub-build_')) return true
|
|
if (id.startsWith('archy-')) return true
|
|
// Backend naming patterns that never carry a user-facing UI: databases and
|
|
// caches. Safe to classify by suffix (a database is never a launcher).
|
|
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
|
|
if (id.endsWith('_db')) return true
|
|
return false
|
|
}
|
|
|
|
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
|
if (isServiceContainer(id)) return true
|
|
const manifestId = pkg?.manifest?.id
|
|
return !!manifestId && isServiceContainer(manifestId)
|
|
}
|
|
|
|
// Known app -> category mappings (matches App Store categorisation)
|
|
export const APP_CATEGORY_MAP: Record<string, string> = {
|
|
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
|
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
|
'fedimint': 'money', 'fedimint-gateway': 'money',
|
|
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
|
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
|
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
|
|
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
|
|
'nostrudel': 'nostr',
|
|
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
|
'uptime-kuma': 'networking',
|
|
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
|
|
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
|
|
}
|
|
|
|
export function getAppCategory(id: string, pkg: PackageDataEntry): string {
|
|
if (APP_CATEGORY_MAP[id]) return APP_CATEGORY_MAP[id]
|
|
const cat = (pkg.manifest as unknown as Record<string, unknown>)?.category as string | undefined
|
|
return cat || 'other'
|
|
}
|
|
|
|
export function runtimeLanAddress(pkg: PackageDataEntry): string {
|
|
return pkg.installed?.['interface-addresses']?.main?.['lan-address'] || ''
|
|
}
|
|
|
|
export function isKnownApp(id: string, pkg?: PackageDataEntry): boolean {
|
|
const manifestId = pkg?.manifest?.id
|
|
return !!(APP_CATEGORY_MAP[id] || (manifestId && APP_CATEGORY_MAP[manifestId]) || isWebOnlyApp(id))
|
|
}
|
|
|
|
// True when the package's manifest declares a front-end UI interface. This is
|
|
// the authoritative "is this a user-facing app?" signal (#45/#51): apps with a
|
|
// UI belong in "My Apps", while headless services (databases, APIs, backends,
|
|
// workers) declare no UI and belong in the "Services" tab.
|
|
export function hasFrontendUi(pkg?: PackageDataEntry): boolean {
|
|
return !!pkg?.manifest?.interfaces?.main?.ui
|
|
}
|
|
|
|
export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean {
|
|
if (isInternalToolingPackage(id, pkg)) return false
|
|
// Headless infra (databases/backends/companions) keyed by container name are
|
|
// services regardless of any stray UI string.
|
|
if (isServicePackage(id, pkg)) return true
|
|
// A declared front-end UI is the deciding factor: it's an app, not a website.
|
|
if (hasFrontendUi(pkg)) return false
|
|
// Curated known apps stay in My Apps even if their manifest predates the UI
|
|
// interface field.
|
|
if (isKnownApp(id, pkg)) return false
|
|
// Anything still here has no declared UI and isn't a known launcher app:
|
|
// databases, APIs, backends, workers. They belong in Services (not My Apps),
|
|
// whether or not they expose a LAN address. (#10 — "anything that isn't the
|
|
// frontend UI launcher".)
|
|
return !!pkg
|
|
}
|
|
|
|
export function filterEntriesForTab(
|
|
entries: Array<[string, PackageDataEntry]>,
|
|
activeTab: AppsTab,
|
|
selectedCategory: string,
|
|
): Array<[string, PackageDataEntry]> {
|
|
return entries.filter(([id, pkg]) => {
|
|
if (isInternalToolingPackage(id, pkg)) return false
|
|
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
|
|
const isWebsite = isWebsitePackage(id, pkg)
|
|
if (wantsWebsites ? !isWebsite : isWebsite) return false
|
|
if (activeTab === 'apps' && selectedCategory !== 'all') {
|
|
return getAppCategory(id, pkg) === selectedCategory
|
|
}
|
|
if (activeTab === 'services' && selectedCategory !== 'all') {
|
|
return getServiceCategory(id, pkg) === selectedCategory
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// Group a (non-launcher) service container by type for the Services tab sub-nav
|
|
// (#12). Heuristic over the container id + manifest id.
|
|
export function getServiceCategory(id: string, pkg?: PackageDataEntry): string {
|
|
const s = `${id} ${pkg?.manifest?.id || ''}`.toLowerCase()
|
|
if (/postgres|mariadb|mysql|(^|[-_])db([-_]|$)/.test(s)) return 'database'
|
|
if (/redis|valkey|(^|[-_])cache([-_]|$)/.test(s)) return 'cache'
|
|
if (/(^|[-_])api([-_]|$)/.test(s)) return 'api'
|
|
return 'backend'
|
|
}
|
|
|
|
export function buildServiceCategories(t: (key: string) => string): Array<{ id: string; name: string }> {
|
|
return [
|
|
{ id: 'all', name: t('marketplace.all') },
|
|
{ id: 'database', name: 'Databases' },
|
|
{ id: 'cache', name: 'Caches' },
|
|
{ id: 'api', name: 'APIs' },
|
|
{ id: 'backend', name: 'Backends' },
|
|
]
|
|
}
|
|
|
|
// Web-only app IDs and their URLs
|
|
export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
|
'nwnn': 'https://nwnn.l484.com',
|
|
'484-kitchen': 'https://484.kitchen',
|
|
'call-the-operator': 'https://cta.tx1138.com',
|
|
'arch-presentation': 'https://present.l484.com',
|
|
'syntropy-institute': 'https://syntropy.institute',
|
|
't-zero': 'https://teeminuszero.net',
|
|
}
|
|
|
|
export function isWebOnlyApp(id: string): boolean {
|
|
return id in WEB_ONLY_APP_URLS
|
|
}
|
|
|
|
// Web-only apps (no container) -- always show as installed bookmarks
|
|
export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
|
'nwnn': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'nwnn', title: 'Next Web News Network', version: '1.0.0', description: { short: 'Decentralized news aggregator, synced from Telegram', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/nwnn.png' },
|
|
},
|
|
'484-kitchen': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: '484-kitchen', title: '484 Kitchen', version: '1.0.0', description: { short: 'K484 application platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/484-kitchen.png' },
|
|
},
|
|
'call-the-operator': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
|
},
|
|
'arch-presentation': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
|
|
},
|
|
'syntropy-institute': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/syntropy-institute.png' },
|
|
},
|
|
't-zero': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 't-zero', title: 'T-0', version: '1.0.0', description: { short: 'Documentary series on decentralization and Bitcoin', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/t-zero.png' },
|
|
},
|
|
}
|
|
|
|
/** Apps that open in a new browser tab (X-Frame-Options blocks iframe) */
|
|
export const TAB_LAUNCH_APPS = new Set([
|
|
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
|
|
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
|
|
'cryptpad', 'nginx-proxy-manager', 'tailscale',
|
|
// netbird's dashboard needs HTTPS (secure context) so it opens in a new tab
|
|
'netbird',
|
|
])
|
|
|
|
export function opensInTab(id: string): boolean {
|
|
return TAB_LAUNCH_APPS.has(id)
|
|
}
|
|
|
|
// Backend services that ship no icon of their own reuse their PARENT app's icon
|
|
// (#14) so they render the app's logo instead of a 404 → 📦 placeholder. Paths
|
|
// are explicit because icon extensions vary (.png / .webp / .svg).
|
|
const APP_ICON_FALLBACKS: Record<string, string> = {
|
|
gitea: '/assets/img/app-icons/gitea.svg',
|
|
'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
|
|
'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
|
|
// immich stack
|
|
'immich-postgres': '/assets/img/app-icons/immich.png',
|
|
'immich-redis': '/assets/img/app-icons/immich.png',
|
|
'immich-server': '/assets/img/app-icons/immich.png',
|
|
'immich_postgres': '/assets/img/app-icons/immich.png',
|
|
'immich_redis': '/assets/img/app-icons/immich.png',
|
|
// btcpay stack
|
|
'archy-btcpay-db': '/assets/img/app-icons/btcpay-server.png',
|
|
'archy-nbxplorer': '/assets/img/app-icons/btcpay-server.png',
|
|
// mempool stack
|
|
'archy-mempool-db': '/assets/img/app-icons/mempool.webp',
|
|
'mempool-api': '/assets/img/app-icons/mempool.webp',
|
|
'archy-mempool-web': '/assets/img/app-icons/mempool.webp',
|
|
'mysql-mempool': '/assets/img/app-icons/mempool.webp',
|
|
// bitcoin / lightning companion UIs
|
|
'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp',
|
|
'archy-lnd-ui': '/assets/img/app-icons/lnd.svg',
|
|
'archy-electrs-ui': '/assets/img/app-icons/electrumx.png',
|
|
// ElectrumX ships under a few historical ids (the backend was renamed
|
|
// electrs → electrumx). Without an explicit map, an `electrs`-keyed install
|
|
// falls through to the default `/assets/img/app-icons/electrs.png`, which
|
|
// doesn't exist → handleImageError swaps .png→.svg and lands on electrs.svg
|
|
// (the "Electrs in Rust" logo) instead of the real ElectrumX icon. Pin the
|
|
// whole family to the ElectrumX icon so My Apps shows the right logo no
|
|
// matter which id the node has it installed under.
|
|
'electrs': '/assets/img/app-icons/electrumx.png',
|
|
'electrs-ui': '/assets/img/app-icons/electrumx.png',
|
|
'electrumx': '/assets/img/app-icons/electrumx.png',
|
|
}
|
|
|
|
// Parent-app icon by prefix, for stack members not listed explicitly above
|
|
// (e.g. every indeedhub-* sub-container → indeedhub).
|
|
const SERVICE_ICON_PREFIXES: Array<[string, string]> = [
|
|
['indeedhub-', '/assets/img/app-icons/indeedhub.png'],
|
|
['immich-', '/assets/img/app-icons/immich.png'],
|
|
['immich_', '/assets/img/app-icons/immich.png'],
|
|
]
|
|
|
|
function serviceParentIcon(id: string): string | undefined {
|
|
for (const [prefix, icon] of SERVICE_ICON_PREFIXES) {
|
|
if (id.startsWith(prefix)) return icon
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
|
|
|
|
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
|
const rawIcon = (pkg["static-files"]?.icon || "").trim()
|
|
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
|
|
if (
|
|
icon.startsWith("/") ||
|
|
icon.startsWith("http://") ||
|
|
icon.startsWith("https://") ||
|
|
icon.startsWith("data:image")
|
|
) {
|
|
return icon
|
|
}
|
|
return (
|
|
curatedIcon ||
|
|
APP_ICON_FALLBACKS[id] ||
|
|
serviceParentIcon(id) ||
|
|
`/assets/img/app-icons/${id}.png`
|
|
)
|
|
}
|
|
|
|
export function canLaunch(pkg: PackageDataEntry): boolean {
|
|
if (isWebOnlyApp(pkg.manifest.id)) return true
|
|
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
|
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
|
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
|
|
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
|
|
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
|
|
}
|
|
// A static launch URL (e.g. a host-networked companion UI like
|
|
// archy-electrs-ui) serves independently of the backend's own sync state, so
|
|
// the tile stays launchable while the backend is still 'starting' (ElectrumX
|
|
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
|
|
// blocks. Apps that rely on a runtime interface-address keep the strict gate.
|
|
const blockedByHealth =
|
|
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl)
|
|
return !!hasUI && pkg.state === 'running' && !blockedByHealth
|
|
}
|
|
|
|
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
|
const appId = pkg?.manifest?.id || id
|
|
if (
|
|
(appId === 'fedimint' || appId === 'fedimintd') &&
|
|
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
|
|
) {
|
|
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
|
|
}
|
|
return ''
|
|
}
|
|
|
|
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
|
const addr = runtimeLanAddress(pkg)
|
|
if (!addr || typeof window === 'undefined') return addr
|
|
return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
|
|
}
|
|
|
|
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
|
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
|
|
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
|
|
switch (state) {
|
|
case PackageState.Running:
|
|
return 'bg-green-500/20 text-green-200'
|
|
case PackageState.Stopped:
|
|
return 'bg-gray-500/20 text-gray-200'
|
|
case PackageState.Exited:
|
|
// Exit code 0 = clean shutdown (gray), non-zero = crash (red)
|
|
return exitCode != null && exitCode !== 0
|
|
? 'bg-red-500/20 text-red-200'
|
|
: 'bg-gray-500/20 text-gray-200'
|
|
case PackageState.Starting:
|
|
case PackageState.Stopping:
|
|
case PackageState.Restarting:
|
|
return 'bg-yellow-500/20 text-yellow-200'
|
|
case PackageState.Installing:
|
|
return 'bg-blue-500/20 text-blue-200'
|
|
case PackageState.Updating:
|
|
return 'bg-orange-500/20 text-orange-200'
|
|
default:
|
|
return 'bg-gray-500/20 text-gray-200'
|
|
}
|
|
}
|
|
|
|
export function getStatusLabel(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
|
if (state === PackageState.Running && health === 'starting') return 'starting up'
|
|
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
|
|
if (state === PackageState.Running && health === 'healthy') return 'healthy'
|
|
if (state === PackageState.Updating) return 'updating...'
|
|
if (state === PackageState.Running) return 'running'
|
|
if (state === PackageState.Exited || state === PackageState.Stopped) {
|
|
if (exitCode === 137) return 'killed (SIGKILL)'
|
|
if (exitCode != null && exitCode !== 0) return 'crashed'
|
|
return 'stopped'
|
|
}
|
|
return state
|
|
}
|
|
|
|
export function buildAllCategories(t: (key: string) => string) {
|
|
return [
|
|
{ id: 'all', name: t('marketplace.all') },
|
|
{ id: 'community', name: t('marketplace.community') },
|
|
{ id: 'nostr', name: 'Nostr' },
|
|
{ id: 'commerce', name: t('marketplace.commerce') },
|
|
{ id: 'money', name: t('marketplace.money') },
|
|
{ id: 'data', name: t('marketplace.data') },
|
|
{ id: 'media', name: 'Media' },
|
|
{ id: 'home', name: t('marketplace.homeCategory') },
|
|
{ id: 'networking', name: t('marketplace.networking') },
|
|
{ id: 'l484', name: 'L484' },
|
|
{ id: 'other', name: t('marketplace.other') },
|
|
]
|
|
}
|
|
|
|
export function useCategoriesWithApps(
|
|
packages: Ref<Record<string, PackageDataEntry>>,
|
|
allCategories: Ref<Array<{ id: string; name: string }>>,
|
|
) {
|
|
return computed(() => {
|
|
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
|
|
return allCategories.value.filter(cat => {
|
|
if (cat.id === 'all') return true
|
|
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
|
|
})
|
|
})
|
|
}
|
|
|
|
// Services-tab equivalent of useCategoriesWithApps: only show a service category
|
|
// when at least one installed service belongs to it (#12).
|
|
export function useServiceCategories(
|
|
packages: Ref<Record<string, PackageDataEntry>>,
|
|
serviceCategories: Ref<Array<{ id: string; name: string }>>,
|
|
) {
|
|
return computed(() => {
|
|
const entries = Object.entries(packages.value).filter(([id, pkg]) => isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
|
|
return serviceCategories.value.filter(cat => {
|
|
if (cat.id === 'all') return true
|
|
return entries.some(([id, pkg]) => getServiceCategory(id, pkg) === cat.id)
|
|
})
|
|
})
|
|
}
|
|
|
|
export function handleImageError(e: Event) {
|
|
const target = e.target as HTMLImageElement
|
|
const currentSrc = target.src
|
|
|
|
if (target.dataset.fallbackTried !== "1" && currentSrc.endsWith(".png")) {
|
|
target.dataset.fallbackTried = "1"
|
|
target.src = currentSrc.replace(/\.png($|\?)/, ".svg$1")
|
|
return
|
|
}
|
|
|
|
if (!currentSrc.includes(DEFAULT_APP_ICON)) {
|
|
target.src = DEFAULT_APP_ICON
|
|
target.dataset.defaultIcon = "1"
|
|
}
|
|
}
|