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:
co-authored by
Claude Opus 4.6
parent
27eabbce92
commit
dc140ac457
@@ -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 |
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user