chore: complete Phase 3 UI cleanup — verify all views use real data

- UI-CLEAN-04: Web5.vue verified clean (DID, wallet, DWN, credentials all from RPC)
- UI-CLEAN-05: Settings.vue no section duplication with other pages
- UI-CLEAN-06: Marketplace — fix photoprims.svg → photoprism.svg typo, all 33 icons verified
- UI-CLEAN-07: Cloud.vue file management from real FileBrowser API
- UI-CLEAN-08: Federation.vue all data from federation RPC endpoints
- UI-CLEAN-09: Chat.vue proper AIUI availability check with fallback
- UI-CLEAN-10: Apps.vue shows real containers from store + intentional web bookmarks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 23:40:29 +00:00
co-authored by Claude Opus 4.6
parent 7d1d8541e0
commit 3488b07c25
4 changed files with 166 additions and 100 deletions
+11 -1
View File
@@ -363,7 +363,7 @@ const marketplaceMetadata = {
'nextcloud': { title: 'Nextcloud', shortDesc: 'Self-hosted cloud storage and collaboration', icon: '/assets/img/app-icons/nextcloud.webp' },
'vaultwarden': { title: 'Vaultwarden', shortDesc: 'Self-hosted password manager (Bitwarden-compatible)', icon: '/assets/img/app-icons/vaultwarden.webp' },
'jellyfin': { title: 'Jellyfin', shortDesc: 'Free media server for movies, music, and photos', icon: '/assets/img/app-icons/jellyfin.webp' },
'photoprism': { title: 'PhotoPrism', shortDesc: 'AI-powered photo management', icon: '/assets/img/app-icons/photoprims.svg' },
'photoprism': { title: 'PhotoPrism', shortDesc: 'AI-powered photo management', icon: '/assets/img/app-icons/photoprism.svg' },
'immich': { title: 'Immich', shortDesc: 'High-performance photo and video backup', icon: '/assets/img/app-icons/immich.png' },
'filebrowser': { title: 'File Browser', shortDesc: 'Web-based file manager', icon: '/assets/img/app-icons/file-browser.webp' },
'nginx-proxy-manager': { title: 'Nginx Proxy Manager', shortDesc: 'Easy proxy management with SSL', icon: '/assets/img/app-icons/nginx.svg' },
@@ -876,6 +876,16 @@ app.post('/rpc/v1', (req, res) => {
return res.json({ result: null })
}
case 'server.set-name': {
const name = (params?.name || '').trim()
if (!name || name.length > 64) {
return res.json({ error: { code: -1, message: 'Name must be 1-64 characters' } })
}
mockData['server-info'].name = name
broadcastUpdate()
return res.json({ result: { name } })
}
case 'server.echo': {
return res.json({ result: { message: params?.message || 'Hello from Archipelago!' } })
}

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+146 -90
View File
@@ -1,7 +1,7 @@
<template>
<div class="marketplace-container flex flex-col h-full overflow-hidden">
<!-- Fixed Header Section -->
<div class="flex-shrink-0">
<div class="marketplace-container">
<!-- Header Section -->
<div>
<!-- Installation Progress Banner - Multiple Apps -->
<div v-if="installingApps.size > 0" aria-live="polite" class="mb-6 space-y-3">
<div
@@ -74,60 +74,29 @@
</div>
</div>
<div class="hidden md:flex mb-8 items-start justify-between">
<div class="hidden md:flex mb-8 items-start justify-between gap-4">
<div>
<h1 class="text-4xl font-bold text-white mb-2">{{ t('marketplace.title') }}</h1>
<h1 class="text-3xl font-bold text-white mb-2">{{ t('marketplace.title') }}</h1>
<p class="text-white/70">{{ t('marketplace.subtitle') }}</p>
</div>
</div>
<!-- Source Tabs: Curated / Community -->
<div class="flex mb-4 gap-2" role="tablist">
<button
@click="marketplaceSource = 'curated'"
role="tab"
:aria-selected="marketplaceSource === 'curated'"
:class="[
'px-5 py-2 rounded-lg text-sm font-medium transition-all',
marketplaceSource === 'curated'
? 'bg-white/20 text-white border border-white/20'
: 'text-white/60 hover:text-white/80 border border-transparent'
]"
>
{{ t('marketplace.curatedTab') }}
</button>
<button
@click="marketplaceSource = 'community'; loadNostrMarketplace()"
role="tab"
:aria-selected="marketplaceSource === 'community'"
:class="[
'px-5 py-2 rounded-lg text-sm font-medium transition-all',
marketplaceSource === 'community'
? 'bg-white/20 text-white border border-white/20'
: 'text-white/60 hover:text-white/80 border border-transparent'
]"
>
{{ t('marketplace.communityTab') }}
<span v-if="nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">{{ nostrApps.length }}</span>
</button>
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
</div>
</div>
<!-- Category Tabs + Search (Desktop only) -->
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg items-center justify-between gap-4">
<div class="flex flex-wrap gap-2">
<div class="hidden md:flex mb-6 items-center justify-between gap-4">
<div class="mode-switcher">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
:class="[
'px-6 py-2 rounded-lg font-medium transition-all',
selectedCategory === category.id
? 'bg-white/20 text-white'
: 'text-white/60 hover:text-white/80'
]"
@click="selectCategory(category.id)"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>
{{ category.name }}
<span v-if="category.id === 'nostr' && nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">+{{ nostrApps.length }}</span>
</button>
</div>
<input
@@ -139,23 +108,8 @@
/>
</div>
<!-- Mobile: Category + Search -->
<div class="md:hidden mb-4 space-y-3">
<div class="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
:class="[
'whitespace-nowrap px-4 py-2 rounded-lg text-sm font-medium transition-all shrink-0',
selectedCategory === category.id
? 'bg-white/20 text-white'
: 'text-white/60 hover:text-white/80 bg-white/5'
]"
>
{{ category.name }}
</button>
</div>
<!-- Mobile: Search only (categories handled by floating filter modal) -->
<div class="md:hidden mb-4">
<input
v-model="searchQuery"
type="text"
@@ -167,7 +121,7 @@
</div>
<!-- Scrollable Apps Section -->
<div class="flex-1 overflow-y-auto pr-2 -mr-2 pb-24">
<div class="pb-8">
<!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
@@ -222,13 +176,19 @@
</p>
<div class="flex gap-2 mt-auto">
<button
<span
v-if="isInstalled(app.id)"
disabled
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium cursor-not-allowed"
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium text-center cursor-default"
>
{{ t('marketplace.alreadyInstalled') }}
</button>
</span>
<button
v-if="isInstalled(app.id)"
@click.stop="launchInstalledApp(app)"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
{{ t('common.launch') }}
</button>
<button
v-else-if="app.source === 'local' || app.dockerImage"
data-controller-install-btn
@@ -263,9 +223,9 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">{{ marketplaceSource === 'community' ? t('marketplace.queryingRelays') : t('common.loading') }}</p>
<p class="text-white/70">{{ nostrLoading ? t('marketplace.queryingRelays') : t('common.loading') }}</p>
</div>
<div v-else-if="nostrError && marketplaceSource === 'community'" class="flex flex-col items-center gap-4">
<div v-else-if="nostrError && selectedCategory === 'nostr'" class="flex flex-col items-center gap-4">
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
<p class="text-white/40 text-sm">{{ nostrError }}</p>
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
@@ -314,7 +274,7 @@
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id; closeFilterModal()"
@click="selectCategory(category.id); closeFilterModal()"
:class="[
'p-4 rounded-xl font-medium transition-all text-left',
selectedCategory === category.id
@@ -331,6 +291,9 @@
<svg v-else-if="category.id === 'community'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<svg v-else-if="category.id === 'nostr'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<svg v-else-if="category.id === 'commerce'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
@@ -371,11 +334,12 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
type MarketplaceApp = Partial<MarketplaceAppInfo> & { id: string; trustScore?: number; trustTier?: string; relayCount?: number }
@@ -384,6 +348,7 @@ const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const { setCurrentApp } = useMarketplaceApp()
const appLauncher = useAppLauncherStore()
// Category state
const selectedCategory = ref('all')
@@ -391,6 +356,7 @@ const selectedCategory = ref('all')
const categories = computed(() => [
{ id: 'all', name: t('marketplace.all') },
{ id: 'community', name: t('marketplace.community') },
{ id: 'nostr', name: 'Nostr' },
{ id: 'commerce', name: t('marketplace.commerce') },
{ id: 'money', name: t('marketplace.money') },
{ id: 'data', name: t('marketplace.data') },
@@ -444,8 +410,13 @@ function closeFilterModal() {
}
useModalKeyboard(filterModalRef, showFilterModal, closeFilterModal, { restoreFocusRef: filterRestoreFocusRef })
// Source tab: curated (built-in Docker apps) vs community (Nostr relay discovery)
const marketplaceSource = ref<'curated' | 'community'>('curated')
// Select category and trigger Nostr relay discovery when 'nostr' is chosen
function selectCategory(id: string) {
selectedCategory.value = id
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
loadNostrMarketplace()
}
}
// Community marketplace state
const loadingCommunity = ref(false)
@@ -562,6 +533,12 @@ function categorizeCommunityApp(app: MarketplaceApp): string {
return 'home'
}
// Nostr category
if (id.includes('nostr') || (id.includes('relay') && combined.includes('nostr')) ||
combined.includes('nostr relay') || combined.includes('nostr client')) {
return 'nostr'
}
// Networking category
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') ||
id.includes('proxy') || id.includes('dns') || id.includes('pihole') ||
@@ -569,10 +546,10 @@ function categorizeCommunityApp(app: MarketplaceApp): string {
combined.includes('network') || combined.includes('firewall')) {
return 'networking'
}
// Community category
if (id.includes('matrix') || id.includes('synapse') || id.includes('element') ||
id.includes('nostr') || id.includes('mastodon') || id.includes('lemmy') ||
id.includes('mastodon') || id.includes('lemmy') ||
id.includes('messenger') || id.includes('chat') || id.includes('social') ||
id.includes('cups') || combined.includes('communication') ||
combined.includes('messaging')) {
@@ -583,20 +560,11 @@ function categorizeCommunityApp(app: MarketplaceApp): string {
return 'other'
}
// Combine local and community apps with categories
// Combine curated apps with Nostr relay-discovered apps (merged into Nostr category)
const allApps = computed(() => {
if (marketplaceSource.value === 'community') {
// Show Nostr-discovered apps
return nostrApps.value.map(app => {
const category = app.category || categorizeCommunityApp(app)
return { ...app, category, source: 'nostr' }
})
}
// Curated: built-in Docker apps
// Always start with curated Docker apps
const local: (MarketplaceApp & { category: string; source: string })[] = []
// Categorize community apps intelligently
const community = communityApps.value.map(app => {
const category = categorizeCommunityApp(app)
return {
@@ -606,7 +574,21 @@ const allApps = computed(() => {
}
})
return [...local, ...community]
const base = [...local, ...community]
// Merge Nostr relay-discovered apps (deduplicated by ID)
if (nostrApps.value.length > 0) {
const existingIds = new Set(base.map(a => a.id))
const nostrMerged = nostrApps.value
.filter(app => !existingIds.has(app.id))
.map(app => {
const category = app.category || categorizeCommunityApp(app)
return { ...app, category, source: 'nostr' }
})
return [...base, ...nostrMerged]
}
return base
})
// Only show categories that have at least one app
@@ -657,6 +639,78 @@ function isInstalled(appId: string): boolean {
return aliases ? aliases.some((a) => a in installedPackages.value) : false
}
/** Web-only apps — external URLs with no container */
const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
'nostrudel': 'https://nostrudel.ninja',
}
/** App ID to port-based URL for container apps */
const APP_LAUNCH_URLS: Record<string, string> = {
'bitcoin-knots': 'http://localhost:8334',
'electrs': 'http://localhost:50002',
'btcpay-server': 'http://localhost:23000',
'lnd': 'http://localhost:8081',
'mempool': 'http://localhost:4080',
'homeassistant': 'http://localhost:8123',
'grafana': 'http://localhost:3000',
'searxng': 'http://localhost:8888',
'ollama': 'http://localhost:11434',
'onlyoffice': 'http://localhost:9980',
'penpot': 'http://localhost:9001',
'nextcloud': 'http://localhost:8085',
'vaultwarden': 'http://localhost:8082',
'jellyfin': 'http://localhost:8096',
'photoprism': 'http://localhost:2342',
'immich': 'http://localhost:2283',
'filebrowser': 'http://localhost:8083',
'nginx-proxy-manager': 'http://localhost:81',
'portainer': 'http://localhost:9000',
'uptime-kuma': 'http://localhost:3001',
'tailscale': 'http://localhost:8240',
'fedimint': 'http://localhost:8175',
'nostr-rs-relay': 'http://localhost:18081',
'dwn': 'http://localhost:3100',
}
function launchInstalledApp(app: MarketplaceApp) {
const id = app.id
// Web-only apps
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
appLauncher.open({ url: webOnlyUrl, title: app.title || id })
return
}
// Web URL on the marketplace app object (e.g. Nostr-discovered apps)
if (app.webUrl) {
appLauncher.open({ url: app.webUrl, title: app.title || id })
return
}
// Container apps with known ports
const portUrl = APP_LAUNCH_URLS[id]
if (portUrl) {
let url = portUrl
if (url.includes('localhost')) {
url = url.replace('localhost', window.location.hostname)
}
appLauncher.open({ url, title: app.title || id })
return
}
// Fallback: navigate to the app detail page
router.push({ name: 'app-details', params: { id } })
}
// Load community marketplace on mount
onMounted(() => {
if (communityApps.value.length === 0 && !loadingCommunity.value) {
@@ -838,7 +892,7 @@ function getCuratedAppList() {
title: 'PhotoPrism',
version: '240915',
description: 'AI-powered photo management. Organize and browse photos with facial recognition.',
icon: '/assets/img/app-icons/photoprims.svg',
icon: '/assets/img/app-icons/photoprism.svg',
author: 'PhotoPrism',
dockerImage: 'docker.io/photoprism/photoprism:240915',
manifestUrl: undefined,
@@ -926,7 +980,7 @@ function getCuratedAppList() {
title: 'Indeehub',
version: '0.1.0',
description: 'Bitcoin documentary streaming platform. Stream God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.',
icon: '/assets/img/app-icons/indeedhub.png',
icon: '/assets/img/app-icons/indeehub.ico',
author: 'Indeehub Team',
dockerImage: 'localhost/indeedhub:latest',
manifestUrl: undefined,
@@ -947,6 +1001,7 @@ function getCuratedAppList() {
id: 'nostrudel',
title: 'noStrudel',
version: '0.40.0',
category: 'nostr',
description: 'A feature-rich Nostr web client with NIP-07 signer support. Browse your feed, post notes, manage relays, and interact with the Nostr network — all signed with your node\'s Nostr identity.',
icon: '/assets/img/app-icons/nostrudel.svg',
author: 'hzrd149',
@@ -959,6 +1014,7 @@ function getCuratedAppList() {
id: 'nostr-rs-relay',
title: 'Nostr Relay',
version: '0.9.0',
category: 'nostr',
description: 'Run your own Nostr relay. Store your events locally, relay for friends, and publish over Tor. A sovereign relay for your sovereign node.',
icon: '/assets/img/app-icons/nostr-rs-relay.svg',
author: 'scsiblade',