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:
parent
9e6c5370fc
commit
0860dfacc7
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user