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