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 }"
|
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||||
>{{ category.name }}</button>
|
>{{ category.name }}</button>
|
||||||
</div>
|
</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">
|
<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>
|
<label class="sr-only" for="apps-category-select">My Apps category</label>
|
||||||
<select
|
<select
|
||||||
@ -85,6 +94,16 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>{{ category.name }}</button>
|
>{{ category.name }}</button>
|
||||||
</div>
|
</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">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
@ -367,6 +386,7 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
|||||||
import {
|
import {
|
||||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||||
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
||||||
|
buildServiceCategories, useServiceCategories,
|
||||||
} from './apps/appsConfig'
|
} from './apps/appsConfig'
|
||||||
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
|
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||||
|
|
||||||
@ -418,10 +438,13 @@ watch(searchQuery, (val) => {
|
|||||||
})
|
})
|
||||||
onBeforeUnmount(() => { clearTimeout(searchDebounceTimer) })
|
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')
|
const selectedCategory = ref('all')
|
||||||
|
watch(activeTab, () => { selectedCategory.value = 'all' })
|
||||||
|
|
||||||
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
||||||
|
const SERVICE_CATEGORIES = computed(() => buildServiceCategories(t))
|
||||||
|
|
||||||
const livePackages = computed(() => store.packages || {})
|
const livePackages = computed(() => store.packages || {})
|
||||||
const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] !== false)
|
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 categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
|
||||||
|
const serviceCategoriesWithItems = useServiceCategories(packages, SERVICE_CATEGORIES)
|
||||||
const appsHeaderRef = ref<HTMLElement | null>(null)
|
const appsHeaderRef = ref<HTMLElement | null>(null)
|
||||||
const appsPrimaryRef = ref<HTMLElement | null>(null)
|
const appsPrimaryRef = ref<HTMLElement | null>(null)
|
||||||
const appsCategoryProbeRef = ref<HTMLElement | null>(null)
|
const appsCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||||
|
|||||||
@ -294,9 +294,13 @@ let swipeSuppressed = false
|
|||||||
function onContentTouchStart(e: TouchEvent) {
|
function onContentTouchStart(e: TouchEvent) {
|
||||||
const t = e.touches[0]
|
const t = e.touches[0]
|
||||||
if (!t) return
|
if (!t) return
|
||||||
// Don't begin a tab swipe when the gesture starts on an app icon — let the
|
// Don't begin a tab swipe when the gesture starts on an app icon (let the icon
|
||||||
// icon handle the tap/long-press. Swiping anywhere else still changes tabs.
|
// handle tap/long-press) or on a horizontally-scrollable category strip (let
|
||||||
swipeSuppressed = !!(e.target instanceof Element && e.target.closest('.app-icon-item'))
|
// 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
|
touchStartX = t.clientX
|
||||||
touchStartY = t.clientY
|
touchStartY = t.clientY
|
||||||
touchStartTime = e.timeStamp
|
touchStartTime = e.timeStamp
|
||||||
|
|||||||
@ -15,6 +15,9 @@ export const SERVICE_NAMES = new Set([
|
|||||||
// built-in Mesh tab) belong in Services, not My Apps.
|
// built-in Mesh tab) belong in Services, not My Apps.
|
||||||
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
|
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
|
||||||
'immich_postgres', 'immich_redis',
|
'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',
|
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||||
'bitcoin-ui', 'lnd-ui', '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 (SERVICE_NAMES.has(id)) return true
|
||||||
if (id.startsWith('indeedhub-build_')) return true
|
if (id.startsWith('indeedhub-build_')) return true
|
||||||
if (id.startsWith('archy-')) 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
|
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
|
// Curated known apps stay in My Apps even if their manifest predates the UI
|
||||||
// interface field.
|
// interface field.
|
||||||
if (isKnownApp(id, pkg)) return false
|
if (isKnownApp(id, pkg)) return false
|
||||||
// Fallback: reachable on the LAN but declares no UI → treat as a website.
|
// Anything still here has no declared UI and isn't a known launcher app:
|
||||||
return !!pkg && !!runtimeLanAddress(pkg)
|
// 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(
|
export function filterEntriesForTab(
|
||||||
@ -117,10 +126,33 @@ export function filterEntriesForTab(
|
|||||||
if (activeTab === 'apps' && selectedCategory !== 'all') {
|
if (activeTab === 'apps' && selectedCategory !== 'all') {
|
||||||
return getAppCategory(id, pkg) === selectedCategory
|
return getAppCategory(id, pkg) === selectedCategory
|
||||||
}
|
}
|
||||||
|
if (activeTab === 'services' && selectedCategory !== 'all') {
|
||||||
|
return getServiceCategory(id, pkg) === selectedCategory
|
||||||
|
}
|
||||||
return true
|
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
|
// Web-only app IDs and their URLs
|
||||||
export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||||
'nwnn': 'https://nwnn.l484.com',
|
'nwnn': 'https://nwnn.l484.com',
|
||||||
@ -182,12 +214,46 @@ export function opensInTab(id: string): boolean {
|
|||||||
return TAB_LAUNCH_APPS.has(id)
|
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> = {
|
const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||||
gitea: '/assets/img/app-icons/gitea.svg',
|
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-gateway': '/assets/img/app-icons/fedimint.png',
|
||||||
'fedimint-clientd': '/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'
|
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 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 {
|
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) {
|
export function handleImageError(e: Event) {
|
||||||
const target = e.target as HTMLImageElement
|
const target = e.target as HTMLImageElement
|
||||||
const currentSrc = target.src
|
const currentSrc = target.src
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user