feat(ui): Services tab — backend classification, parent icons, categories sub-nav

- Classify databases/APIs/backends into Services (#10): add immich-postgres/redis
  to SERVICE_NAMES; isServiceContainer matches -postgres/-redis/-valkey/-cache/-db
  suffixes; isWebsitePackage final fallback now routes any no-UI, non-known package
  to Services ("anything that isn't the frontend UI launcher").
- Services show their parent app's icon (#14): backends reuse the app logo
  (immich-* → immich, archy-btcpay-db → btcpay, indeedhub-* → indeedhub, etc.)
  via explicit APP_ICON_FALLBACKS + prefix map, instead of 404 → 📦.
- Categories sub-nav for Services (#12): getServiceCategory + buildServiceCategories
  + useServiceCategories; Services tab gets the same desktop/mobile category strips
  (Databases/Caches/APIs/Backends), shown only for categories with items. Shared
  selectedCategory resets to 'all' on tab switch.
- Mobile swipe (#11): the tab-swipe gesture is suppressed over .mobile-category-strip
  so swiping the category chips scrolls them instead of changing tabs (covers both
  My Apps and the new Services strip).

vue-tsc build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-21 07:42:48 -04:00
parent 9e6c5370fc
commit 0860dfacc7
4 changed files with 124 additions and 10 deletions

View File

@ -20,6 +20,15 @@
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<div v-show="activeTab === 'services' && serviceCategoriesWithItems.length > 1" class="mode-switcher category-tabs-wide hidden md:inline-flex">
<button
v-for="category in serviceCategoriesWithItems"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<div v-show="activeTab === 'apps' && categoriesWithApps.length > 1 && collapseCategories" class="segmented-select flex-shrink-0">
<label class="sr-only" for="apps-category-select">My Apps category</label>
<select
@ -85,6 +94,16 @@
type="button"
>{{ category.name }}</button>
</div>
<div v-if="activeTab === 'services' && serviceCategoriesWithItems.length > 1" class="mobile-category-strip mb-3" aria-label="Services categories">
<button
v-for="category in serviceCategoriesWithItems"
:key="category.id"
@click="selectedCategory = category.id"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
type="button"
>{{ category.name }}</button>
</div>
<div class="flex items-center gap-2">
<input
v-model="searchQuery"
@ -367,6 +386,7 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import {
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
buildServiceCategories, useServiceCategories,
} from './apps/appsConfig'
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
@ -418,10 +438,13 @@ watch(searchQuery, (val) => {
})
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<HTMLElement | null>(null)
const appsPrimaryRef = ref<HTMLElement | null>(null)
const appsCategoryProbeRef = ref<HTMLElement | null>(null)

View File

@ -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

View File

@ -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<string, string> = {
'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<string, string> = {
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<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