diff --git a/apps/immich-server/manifest.yml b/apps/immich/manifest.yml similarity index 100% rename from apps/immich-server/manifest.yml rename to apps/immich/manifest.yml diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index e85a8bfa..be850005 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -20,6 +20,15 @@ :class="{ 'mode-switcher-btn-active': selectedCategory === category.id }" >{{ category.name }} +
{ }) onBeforeUnmount(() => { clearTimeout(searchDebounceTimer) }) -// Category filter +// Category filter (shared by My Apps and Services; reset when switching tabs so +// an apps-category selection never carries into the Services sub-nav). const selectedCategory = ref('all') +watch(activeTab, () => { selectedCategory.value = 'all' }) const ALL_CATEGORIES = computed(() => buildAllCategories(t)) +const SERVICE_CATEGORIES = computed(() => buildServiceCategories(t)) const livePackages = computed(() => store.packages || {}) const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] !== false) @@ -457,6 +480,7 @@ const packages = computed(() => { }) const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES) +const serviceCategoriesWithItems = useServiceCategories(packages, SERVICE_CATEGORIES) const appsHeaderRef = ref(null) const appsPrimaryRef = ref(null) const appsCategoryProbeRef = ref(null) diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index bc6b1580..e909cb0e 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -294,9 +294,13 @@ let swipeSuppressed = false function onContentTouchStart(e: TouchEvent) { const t = e.touches[0] if (!t) return - // Don't begin a tab swipe when the gesture starts on an app icon — let the - // icon handle the tap/long-press. Swiping anywhere else still changes tabs. - swipeSuppressed = !!(e.target instanceof Element && e.target.closest('.app-icon-item')) + // Don't begin a tab swipe when the gesture starts on an app icon (let the icon + // handle tap/long-press) or on a horizontally-scrollable category strip (let + // it scroll its own chips). Swiping anywhere else still changes tabs. + swipeSuppressed = !!( + e.target instanceof Element && + e.target.closest('.app-icon-item, .mobile-category-strip') + ) touchStartX = t.clientX touchStartY = t.clientY touchStartTime = e.timeStamp diff --git a/neode-ui/src/views/apps/appsConfig.ts b/neode-ui/src/views/apps/appsConfig.ts index 0a0b35ee..94920673 100644 --- a/neode-ui/src/views/apps/appsConfig.ts +++ b/neode-ui/src/views/apps/appsConfig.ts @@ -15,6 +15,9 @@ export const SERVICE_NAMES = new Set([ // 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', @@ -41,7 +44,10 @@ 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 - if (id.endsWith('_db') || id.endsWith('-db')) 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 } @@ -100,8 +106,11 @@ export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean { // Curated known apps stay in My Apps even if their manifest predates the UI // interface field. if (isKnownApp(id, pkg)) return false - // Fallback: reachable on the LAN but declares no UI → treat as a website. - return !!pkg && !!runtimeLanAddress(pkg) + // 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( @@ -117,10 +126,33 @@ export function filterEntriesForTab( 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 = { 'nwnn': 'https://nwnn.l484.com', @@ -182,12 +214,46 @@ 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 = { gitea: '/assets/img/app-icons/gitea.svg', - // The Fedimint sub-apps ship no icon of their own; reuse the Fedimint icon so - // they render correctly instead of falling through to a 404 → 📦 placeholder. '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', +} + +// 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' @@ -203,7 +269,12 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: ) { return icon } - return curatedIcon || APP_ICON_FALLBACKS[id] || `/assets/img/app-icons/${id}.png` + return ( + curatedIcon || + APP_ICON_FALLBACKS[id] || + serviceParentIcon(id) || + `/assets/img/app-icons/${id}.png` + ) } export function canLaunch(pkg: PackageDataEntry): boolean { @@ -310,6 +381,21 @@ export function useCategoriesWithApps( }) } +// 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>, + serviceCategories: Ref>, +) { + 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