Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Apps Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pb-8">
|
||||
<template v-if="isLoading && filteredApps.length === 0">
|
||||
<div
|
||||
v-for="index in 6"
|
||||
:key="`loading-${index}`"
|
||||
class="glass-card p-5 flex flex-col app-card-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-3">
|
||||
<div class="app-card-skeleton-icon"></div>
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
|
||||
<div class="app-card-skeleton-line w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-skeleton-line w-full mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
|
||||
<div class="app-card-skeleton-button mt-auto"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="(app, index) in filteredApps"
|
||||
:key="app.id"
|
||||
data-controller-container
|
||||
:data-controller-install="!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card p-5 transition-all hover:-translate-y-1 cursor-pointer flex flex-col"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': index + staggerOffset }"
|
||||
@click="$emit('view-details', app)"
|
||||
@keydown.enter="$emit('view-details', app)"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-3">
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-14 h-14 rounded-lg object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-14 h-14 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<h3 class="text-lg font-semibold text-white truncate">{{ app.title }}</h3>
|
||||
<span
|
||||
v-if="getAppTier(app.id) !== 'optional'"
|
||||
class="tier-badge"
|
||||
:class="getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ getAppTier(app.id) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/50">{{ app.version ? $ver(app.version) : 'latest' }}</p>
|
||||
<p v-if="app.author" class="text-xs text-white/40 mt-0.5">{{ app.author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trust badge for Nostr apps -->
|
||||
<div v-if="app.trustTier" class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="{
|
||||
'bg-green-400/20 text-green-400': app.trustTier === 'verified',
|
||||
'bg-yellow-400/20 text-yellow-400': app.trustTier === 'community',
|
||||
'bg-orange-400/20 text-orange-400': app.trustTier === 'unverified',
|
||||
'bg-red-400/20 text-red-400': app.trustTier === 'untrusted',
|
||||
}"
|
||||
>{{ app.trustTier }}</span>
|
||||
<span class="text-xs text-white/40">Score: {{ app.trustScore }}/100</span>
|
||||
</div>
|
||||
|
||||
<p class="text-white/70 text-sm mb-4 line-clamp-3 flex-1">
|
||||
{{ typeof app.description === 'object' ? app.description.short : (app.description || 'No description available') }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 mt-auto">
|
||||
<!-- Installed & starting up -->
|
||||
<span
|
||||
v-if="isInstalled(app.id) && isStartingUp(app.id)"
|
||||
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-200 text-sm font-medium text-center cursor-default flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<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>
|
||||
{{ getInstalledState(app.id) === 'installing' ? 'Installing...' : 'Starting...' }}
|
||||
</span>
|
||||
<!-- Installed & ready -->
|
||||
<span
|
||||
v-else-if="isInstalled(app.id)"
|
||||
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium text-center cursor-default"
|
||||
>Installed</span>
|
||||
<button
|
||||
v-if="isInstalled(app.id) && !isStartingUp(app.id)"
|
||||
@click.stop="$emit('launch', app)"
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>Launch</button>
|
||||
<!-- Scanning (skipped in demo — there are no real containers to scan) -->
|
||||
<span
|
||||
v-else-if="!IS_DEMO && !containersScanned && (app.source === 'local' || app.dockerImage)"
|
||||
class="flex-1 px-4 py-2 rounded-lg text-white/50 text-sm font-medium text-center cursor-default relative overflow-hidden"
|
||||
>
|
||||
<span class="discover-shimmer-bg"></span>
|
||||
<span class="relative flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5 opacity-60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<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>
|
||||
Checking...
|
||||
</span>
|
||||
</span>
|
||||
<!-- Demo: app not demoable -->
|
||||
<button
|
||||
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
|
||||
>Not available in demo</button>
|
||||
<!-- Install button -->
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
|
||||
data-controller-install-btn
|
||||
@click.stop="$emit('install', app)"
|
||||
:disabled="installingApps.has(app.id)"
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="installingApps.has(app.id)" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<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>
|
||||
{{ installingApps.get(app.id)?.message || 'Installing...' }}
|
||||
</span>
|
||||
<span v-else>Install</span>
|
||||
</button>
|
||||
<!-- Not available -->
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id)"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
|
||||
>Not Available</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredApps.length === 0 && !isLoading" class="text-center py-12">
|
||||
<div v-if="nostrError && isNostrCategory" class="flex flex-col items-center gap-4">
|
||||
<p class="text-white/70">No community apps found</p>
|
||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||
<button @click="$emit('retry-nostr')" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
|
||||
</div>
|
||||
<p v-else class="text-white/70">No apps found{{ searchQuery ? ` for "${searchQuery}"` : '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
defineProps<{
|
||||
filteredApps: MarketplaceApp[]
|
||||
showStagger: boolean
|
||||
staggerOffset: number
|
||||
containersScanned: boolean
|
||||
installingApps: Map<string, { message: string }>
|
||||
isInstalled: (id: string) => boolean
|
||||
isStartingUp: (id: string) => boolean
|
||||
getInstalledState: (id: string) => string | null
|
||||
getAppTier: (id: string) => string
|
||||
isLoading: boolean
|
||||
loadingMessage: string
|
||||
nostrError: string
|
||||
isNostrCategory: boolean
|
||||
searchQuery: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'view-details': [app: MarketplaceApp]
|
||||
'launch': [app: MarketplaceApp]
|
||||
'install': [app: MarketplaceApp]
|
||||
'retry-nostr': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discover-shimmer-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.app-card-skeleton {
|
||||
min-height: 245px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon,
|
||||
.app-card-skeleton-line,
|
||||
.app-card-skeleton-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon::after,
|
||||
.app-card-skeleton-line::after,
|
||||
.app-card-skeleton-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-card-skeleton-line {
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.app-card-skeleton-button {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="btc-face-wrap">
|
||||
<pre class="btc-face-canvas" v-html="frameHtml"></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const frameHtml = ref('')
|
||||
|
||||
const W = 74
|
||||
const H = 38
|
||||
const CX = W / 2
|
||||
const CY = H / 2
|
||||
const RX = 30
|
||||
const RY = 16
|
||||
|
||||
// Bitcoin symbol bitmap (12 wide, 11 tall)
|
||||
const SYM = [
|
||||
' # # ',
|
||||
' ######## ',
|
||||
' ### ## ',
|
||||
' ### ### ',
|
||||
' ### ## ',
|
||||
' ########## ',
|
||||
' ### ## ',
|
||||
' ### ### ',
|
||||
' ### ## ',
|
||||
' ######## ',
|
||||
' # # ',
|
||||
]
|
||||
const SYM_W = SYM[0]!.length
|
||||
const SYM_OX = -Math.floor(SYM_W / 2)
|
||||
const SYM_OY = -Math.floor(SYM.length / 2) + 1
|
||||
|
||||
function isInSym(fx: number, fy: number): boolean {
|
||||
const bx = Math.round(fx) - SYM_OX
|
||||
const by = Math.round(fy) - SYM_OY
|
||||
if (by < 0 || by >= SYM.length || bx < 0 || bx >= SYM_W) return false
|
||||
return SYM[by]![bx] === '#'
|
||||
}
|
||||
|
||||
// Eyes
|
||||
const EYE_Y = -7, EYE_H = 3, EYE_L = -9, EYE_R = 8
|
||||
|
||||
function isEye(fx: number, fy: number): boolean {
|
||||
const ry = Math.round(fy), rx = Math.round(fx)
|
||||
if (rx >= EYE_L - 2 && rx <= EYE_L + 2 && ry >= EYE_Y && ry <= EYE_Y + EYE_H - 1) return true
|
||||
if (rx >= EYE_R - 2 && rx <= EYE_R + 2 && ry >= EYE_Y && ry <= EYE_Y + EYE_H - 1) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isPupil(fx: number, fy: number, lookX: number): boolean {
|
||||
const ry = Math.round(fy), rx = Math.round(fx)
|
||||
const p = Math.round(lookX * 0.8)
|
||||
if (rx >= EYE_L + p && rx <= EYE_L + p + 1 && ry >= EYE_Y && ry <= EYE_Y + 1) return true
|
||||
if (rx >= EYE_R + p && rx <= EYE_R + p + 1 && ry >= EYE_Y && ry <= EYE_Y + 1) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Mouth
|
||||
function isMouth(fx: number, fy: number): boolean {
|
||||
const ry = Math.round(fy), rx = Math.round(fx)
|
||||
if (ry === 9 && rx >= -5 && rx <= 5) return Math.abs(rx) >= 2
|
||||
if (ry === 8 && (rx === -5 || rx === 5)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function mouthChar(fx: number): string {
|
||||
const rx = Math.round(fx)
|
||||
return (rx === -5 || rx === 5) ? "'" : '~'
|
||||
}
|
||||
|
||||
const EDGE = '@#%*+=:. '
|
||||
|
||||
function render(f: number): string {
|
||||
const bob = Math.sin(f * 0.055) * 1.2
|
||||
const breathe = Math.sin(f * 0.028) * 0.4
|
||||
const lookX = Math.sin(f * 0.02)
|
||||
const blink = (f % 160 >= 152 && f % 160 <= 157) || (f % 480 >= 300 && f % 480 <= 305)
|
||||
|
||||
const lines: string[] = []
|
||||
for (let y = 0; y < H; y++) {
|
||||
let line = ''
|
||||
for (let x = 0; x < W; x++) {
|
||||
const fy = y - CY + bob, fx = x - CX
|
||||
const rx = RX + breathe * 2, ry = RY + breathe
|
||||
const dx = fx / rx, dy = fy / ry
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (dist > 1.15) {
|
||||
line += ' '
|
||||
} else if (dist > 1.0) {
|
||||
const sh = Math.sin(f * 0.08 + Math.atan2(dy, dx) * 3) * 0.5 + 0.5
|
||||
const ci = Math.floor(((dist - 1.0) / 0.15) * 6 + sh * 2)
|
||||
const c = EDGE[Math.min(ci, EDGE.length - 1)]
|
||||
line += c === ' ' ? ' ' : `<span class="af-g">${c}</span>`
|
||||
} else if (dist > 0.9) {
|
||||
const sh = Math.sin(f * 0.1 + Math.atan2(dy, dx) * 4) * 0.5 + 0.5
|
||||
let ci = Math.floor((1 - (dist - 0.9) / 0.1) * 3 + sh * 2)
|
||||
ci = Math.max(0, Math.min(ci, 4))
|
||||
line += `<span class="af-b">${'@#%*+'[ci]}</span>`
|
||||
} else if (dist > 0.85) {
|
||||
const sh = Math.sin(f * 0.1 + Math.atan2(dy, dx) * 4 + 1) * 0.5 + 0.5
|
||||
line += `<span class="af-b">${'#%'[Math.floor(sh * 2)]}</span>`
|
||||
} else if (isEye(fx, fy)) {
|
||||
if (blink) line += '<span class="af-b">-</span>'
|
||||
else if (isPupil(fx, fy, lookX)) line += '<span class="af-p">@</span>'
|
||||
else line += ' '
|
||||
} else if (isInSym(fx, fy)) {
|
||||
const gl = Math.sin(f * 0.06 + fx * 0.3) * 0.3
|
||||
line += `<span class="${gl > 0.1 ? 'af-m' : 'af-b'}">$</span>`
|
||||
} else if (isMouth(fx, fy)) {
|
||||
line += `<span class="af-b">${mouthChar(fx)}</span>`
|
||||
} else {
|
||||
const w = Math.sin(f * 0.04 + x * 0.12 + y * 0.08)
|
||||
const d = (dist / 0.85) * 2 + w * 0.6
|
||||
line += d < 1.2 ? '=' : d < 1.6 ? '+' : d < 2.0 ? ':' : d < 2.3 ? '-' : '.'
|
||||
}
|
||||
}
|
||||
lines.push(line)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// Animation loop — 24fps (lighter than 30 for embedded use)
|
||||
let frame = 0, lastTime = -1, running = false, rafId: number | null = null
|
||||
const FT = 1000 / 24
|
||||
|
||||
function tick(ts: number) {
|
||||
if (lastTime === -1) lastTime = ts
|
||||
let dt = ts - lastTime
|
||||
let dirty = false
|
||||
while (dt >= FT) { frame++; dt -= FT; lastTime += FT; dirty = true }
|
||||
if (dirty) frameHtml.value = render(frame)
|
||||
if (running) rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!running) { running = true; lastTime = -1; rafId = requestAnimationFrame(tick) }
|
||||
}
|
||||
|
||||
function stop() {
|
||||
running = false
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null }
|
||||
}
|
||||
|
||||
function onVisChange() {
|
||||
if (document.hidden) stop(); else start()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
frameHtml.value = render(0)
|
||||
} else {
|
||||
document.addEventListener('visibilitychange', onVisChange)
|
||||
start()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
document.removeEventListener('visibilitychange', onVisChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btc-face-wrap {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btc-face-canvas {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 7px;
|
||||
line-height: 1.15;
|
||||
white-space: pre;
|
||||
color: #6a6a6e;
|
||||
letter-spacing: 0.3px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btc-face-canvas :deep(.af-b) { color: #F7931A; }
|
||||
.btc-face-canvas :deep(.af-g) { color: #c98a20; }
|
||||
.btc-face-canvas :deep(.af-p) { color: #ffffff; }
|
||||
.btc-face-canvas :deep(.af-m) { color: #e8a43a; }
|
||||
.btc-face-canvas :deep(.af-d) { color: #52524e; }
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<!-- Companion app banner — same format as the featured app banner, with a
|
||||
phone mockup rising out of the right edge. Clicking anywhere (or the
|
||||
Install button) opens the Remote Companion download/pairing modal. -->
|
||||
<div
|
||||
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="openCompanionIntro()"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/companion-banner-bg.webp"
|
||||
alt=""
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
|
||||
/>
|
||||
<div class="featured-banner-overlay companion-banner-overlay">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="discover-terminal-tag">companion</span>
|
||||
<span class="text-white/50 text-sm font-mono">REMOTE CONTROL // IN YOUR POCKET</span>
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">Your Node. In Your Pocket.</h2>
|
||||
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">
|
||||
The Archipelago Companion puts your node on your phone — remote control and typing,
|
||||
your cloud files on the go, and one scan to connect. Sovereignty doesn't stay home.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click.stop="openCompanionIntro()"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
||||
>Install</button>
|
||||
<span class="text-white/40 text-sm">Archipelago Companion · Android</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="companion-phone-wrap" aria-hidden="true">
|
||||
<div class="companion-phone">
|
||||
<span class="companion-phone-lens"></span>
|
||||
<img src="/assets/img/companion-phone-screen.webp" alt="" class="companion-phone-screen" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.companion-banner {
|
||||
border-color: rgba(251, 146, 60, 0.25);
|
||||
}
|
||||
|
||||
/* Keep the copy clear of the phone mockup on wide screens */
|
||||
@media (min-width: 768px) {
|
||||
.companion-banner-overlay {
|
||||
padding-right: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.companion-phone-wrap {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.companion-phone-wrap {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 3.5rem;
|
||||
z-index: 2;
|
||||
transform: rotate(6deg);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.companion-banner:hover .companion-phone-wrap {
|
||||
transform: rotate(4deg) translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
.companion-phone {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
padding: 9px;
|
||||
border-radius: 30px;
|
||||
background: #0b0b12;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.55),
|
||||
0 0 46px rgba(251, 146, 60, 0.28),
|
||||
inset 0 0 2px rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Punch-hole camera */
|
||||
.companion-phone-lens {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.companion-phone-screen {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
/* Screen glare */
|
||||
.companion-phone::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 9px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(115deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 28%, transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero Section -->
|
||||
<div class="discover-hero glass-card p-8 md:p-12 mb-8 relative overflow-hidden">
|
||||
<div class="discover-hero-scanline" aria-hidden="true"></div>
|
||||
<div class="discover-hero-layout relative z-10">
|
||||
<div class="discover-hero-content">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="discover-terminal-tag">~ $</span>
|
||||
<span class="text-white/40 text-sm font-mono tracking-wider">ARCHIPELAGO://DISCOVER</span>
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight font-archipelago">
|
||||
Reclaim Your<br />
|
||||
<span class="discover-hero-accent">Digital Sovereignty</span>
|
||||
</h1>
|
||||
<p class="text-white/70 text-lg md:text-xl max-w-2xl leading-relaxed mb-6">
|
||||
Your node. Your rules. Every app runs on <em>your</em> hardware, verified by <em>your</em> Bitcoin node.
|
||||
No cloud. No custodians. No permission needed.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ totalApps }}</span>
|
||||
<span class="text-white/50">apps available</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ installedCount }}</span>
|
||||
<span class="text-white/50">installed</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">100%</span>
|
||||
<span class="text-white/50">self-hosted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-hero-face">
|
||||
<BitcoinFaceAscii />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Principles Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-10">
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-base font-bold mb-1">Privacy First</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">No telemetry. No tracking. Your data never leaves your hardware.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-base font-bold mb-1">Verify, Don't Trust</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Run your own node. Validate every transaction. Be your own bank.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
<h3 class="text-white text-base font-bold mb-1">Open Source</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Every app is open source. Audit the code. Trust the math, not the company.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-base font-bold mb-1">No Permission Needed</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Permissionless commerce. Permissionless money. Permissionless freedom.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BitcoinFaceAscii from './BitcoinFaceAscii.vue'
|
||||
|
||||
defineProps<{
|
||||
totalApps: number
|
||||
installedCount: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="mb-10">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<span class="discover-terminal-tag">featured</span>
|
||||
<h2 class="text-xl font-bold text-white">Sovereignty Stack</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div
|
||||
v-for="(app, index) in featuredApps"
|
||||
:key="app.id"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': index }"
|
||||
@click="$emit('view-details', app)"
|
||||
@keydown.enter="$emit('view-details', app)"
|
||||
>
|
||||
<div class="flex items-start gap-5">
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl object-cover flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="text-xl font-bold text-white truncate">{{ app.title }}</h3>
|
||||
<span
|
||||
v-if="getAppTier(app.id) !== 'optional'"
|
||||
class="tier-badge"
|
||||
:class="getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ getAppTier(app.id) }}</span>
|
||||
<span v-if="isInstalled(app.id)" class="discover-installed-badge">installed</span>
|
||||
</div>
|
||||
<p class="text-white/50 text-sm mb-3">{{ app.author }} · {{ $ver(app.version) }}</p>
|
||||
<p class="text-white/80 text-sm leading-relaxed">{{ app.featuredDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-white/8">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-orange-400/70" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-white/60 text-sm font-mono">{{ app.privacyTag }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="isInstalled(app.id) && !isStartingUp(app.id)"
|
||||
@click.stop="$emit('launch', app)"
|
||||
class="glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>Launch</button>
|
||||
<span
|
||||
v-else-if="isInstalled(app.id) && isStartingUp(app.id)"
|
||||
class="text-yellow-200 text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<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>
|
||||
Starting...
|
||||
</span>
|
||||
<button
|
||||
v-else-if="!IS_DEMO && !containersScanned && app.dockerImage"
|
||||
disabled
|
||||
class="text-white/40 text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5 opacity-60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<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>
|
||||
Checking...
|
||||
</button>
|
||||
<button
|
||||
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
|
||||
disabled
|
||||
class="glass-button glass-button-sm rounded-lg text-sm font-medium opacity-50 cursor-not-allowed"
|
||||
>Not available in demo</button>
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id) && app.dockerImage"
|
||||
data-controller-install-btn
|
||||
@click.stop="$emit('install', app)"
|
||||
:disabled="installingApps.has(app.id)"
|
||||
class="glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="installingApps.has(app.id)" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<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>
|
||||
Installing...
|
||||
</span>
|
||||
<span v-else>Install</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FeaturedApp, MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
defineProps<{
|
||||
featuredApps: FeaturedApp[]
|
||||
showStagger: boolean
|
||||
containersScanned: boolean
|
||||
installingApps: Map<string, { message: string }>
|
||||
isInstalled: (id: string) => boolean
|
||||
isStartingUp: (id: string) => boolean
|
||||
getAppTier: (id: string) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'view-details': [app: MarketplaceApp]
|
||||
'launch': [app: MarketplaceApp]
|
||||
'install': [app: MarketplaceApp]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Floating Filter Button (Mobile) -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="showFilter = true"
|
||||
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
|
||||
>
|
||||
<svg 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 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Filter Modal (Mobile) -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showFilter"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/60 backdrop-blur-md"
|
||||
@click.self="closeFilter"
|
||||
>
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">Filter</h2>
|
||||
<button @click="closeFilter" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
@click="$emit('select-category', category.id); closeFilter()"
|
||||
:class="[
|
||||
'p-4 rounded-xl font-medium transition-all text-left',
|
||||
selectedCategory === category.id
|
||||
? 'bg-white/20 text-white border-2 border-white/40'
|
||||
: 'glass-card text-white/80 hover:bg-orange-500/5 hover:border-orange-500/15'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold">{{ category.name }}</p>
|
||||
<p v-if="selectedCategory === category.id" class="text-xs text-white/60 mt-1">Currently viewing</p>
|
||||
</div>
|
||||
<svg v-if="selectedCategory === category.id" class="w-5 h-5 text-white flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import type { CategoryDef } from './types'
|
||||
|
||||
defineProps<{
|
||||
categories: CategoryDef[]
|
||||
selectedCategory: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'select-category': [id: string]
|
||||
}>()
|
||||
|
||||
const showFilter = ref(false)
|
||||
const filterModalRef = ref<HTMLElement | null>(null)
|
||||
const filterRestoreFocusRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function closeFilter() {
|
||||
filterRestoreFocusRef.value?.focus?.()
|
||||
showFilter.value = false
|
||||
}
|
||||
|
||||
useModalKeyboard(filterModalRef, showFilter, closeFilter, { restoreFocusRef: filterRestoreFocusRef })
|
||||
</script>
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { MarketplaceApp } from './types'
|
||||
|
||||
const R = 'source.archipelago-foundation.org/lfg2025'
|
||||
|
||||
// ---------- Dynamic catalog from registry ----------
|
||||
export interface CatalogFeatured {
|
||||
id: string
|
||||
banner: string
|
||||
headline: string
|
||||
description: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface AppCatalog {
|
||||
version: number
|
||||
registry: string
|
||||
featured: CatalogFeatured
|
||||
apps: MarketplaceApp[]
|
||||
}
|
||||
|
||||
let cachedCatalog: AppCatalog | null = null
|
||||
let catalogFetchedAt = 0
|
||||
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
||||
|
||||
/** Catalog URLs tried in order. First success wins.
|
||||
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
|
||||
* bypasses CORS on the upstream Gitea and CSP restrictions on the IP-port
|
||||
* fallback. If the backend is offline (mid-restart etc.) we fall back
|
||||
* to the static copy baked into the frontend build. */
|
||||
const CATALOG_URLS = [
|
||||
'/api/app-catalog',
|
||||
'/catalog.json',
|
||||
]
|
||||
|
||||
/** Fetch app catalog from remote registry, with local fallback.
|
||||
* Caches for 1 hour. Returns null only if ALL sources fail. */
|
||||
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
// Return cache if fresh
|
||||
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog
|
||||
|
||||
for (const url of CATALOG_URLS) {
|
||||
try {
|
||||
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
||||
if (!res.ok) continue
|
||||
const data = await res.json() as AppCatalog
|
||||
if (!data.apps?.length) continue
|
||||
|
||||
// Expand short docker image refs to full registry paths
|
||||
const registry = data.registry || R
|
||||
for (const app of data.apps) {
|
||||
if (app.dockerImage && !app.dockerImage.includes('/')) {
|
||||
app.dockerImage = `${registry}/${app.dockerImage}`
|
||||
}
|
||||
}
|
||||
cachedCatalog = data
|
||||
catalogFetchedAt = Date.now()
|
||||
// Cache in localStorage for offline fallback
|
||||
try { localStorage.setItem('archy_catalog', JSON.stringify(data)) } catch {}
|
||||
return data
|
||||
} catch (e) {
|
||||
console.warn(`[catalog] fetch failed for ${url}:`, e)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Try localStorage cache as final fallback
|
||||
try {
|
||||
const stored = localStorage.getItem('archy_catalog')
|
||||
if (stored) {
|
||||
cachedCatalog = JSON.parse(stored) as AppCatalog
|
||||
catalogFetchedAt = Date.now() - CATALOG_TTL + 5 * 60 * 1000 // re-check in 5 min
|
||||
return cachedCatalog
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[catalog] localStorage fallback unreadable:', e)
|
||||
}
|
||||
|
||||
console.warn('[catalog] all sources failed — using hardcoded app list')
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------- Hardcoded fallback (used when catalog.json is unavailable) ----------
|
||||
export function getCuratedAppList(): MarketplaceApp[] {
|
||||
return [
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
||||
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
|
||||
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.4.2', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.4.2', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
||||
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.png', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
|
||||
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
|
||||
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
|
||||
{ id: 'grafana', title: 'Grafana', version: '10.2.0', description: 'Analytics and monitoring platform. Dashboards for your node metrics and system health.', icon: '/assets/img/app-icons/grafana.png', author: 'Grafana Labs', dockerImage: `${R}/grafana:10.2.0`, repoUrl: 'https://github.com/grafana/grafana' },
|
||||
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
|
||||
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
|
||||
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/icon/favico-black-v2.svg', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
|
||||
{ id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
|
||||
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
|
||||
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
|
||||
{ id: 'photoprism', title: 'PhotoPrism', version: '240915', description: 'AI-powered photo management. Organize photos with facial recognition, privately.', icon: '/assets/img/app-icons/photoprism.svg', author: 'PhotoPrism', dockerImage: `${R}/photoprism:240915`, repoUrl: 'https://github.com/photoprism/photoprism' },
|
||||
{ id: 'immich', title: 'Immich', version: '1.90.0', description: 'High-performance photo and video backup. Mobile-first with ML features.', icon: '/assets/img/app-icons/immich.png', author: 'Immich', dockerImage: `${R}/immich-server:release`, repoUrl: 'https://github.com/immich-app/immich' },
|
||||
{ id: 'filebrowser', title: 'File Browser', version: '2.27.0', description: 'Web-based file manager. Browse, upload, and manage files on your server.', icon: '/assets/img/app-icons/file-browser.webp', author: 'File Browser', dockerImage: `${R}/filebrowser:v2.27.0`, repoUrl: 'https://github.com/filebrowser/filebrowser' },
|
||||
{ id: 'nginx-proxy-manager', title: 'Nginx Proxy Manager', version: '2.12.1', description: 'Reverse proxy with SSL. Beautiful web interface for managing proxies.', icon: '/assets/img/app-icons/nginx.svg', author: 'Nginx Proxy Manager', dockerImage: `${R}/nginx-proxy-manager:latest`, repoUrl: 'https://github.com/NginxProxyManager/nginx-proxy-manager' },
|
||||
{ id: 'portainer', title: 'Portainer', version: '2.19.4', description: 'Container management UI. Manage your containerized services through the web.', icon: '/assets/img/app-icons/portainer.webp', author: 'Portainer', dockerImage: `${R}/portainer:latest`, repoUrl: 'https://github.com/portainer/portainer' },
|
||||
{ id: 'uptime-kuma', title: 'Uptime Kuma', version: '1.23.0', description: 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.', icon: '/assets/img/app-icons/uptime-kuma.webp', author: 'Uptime Kuma', dockerImage: `${R}/uptime-kuma:1`, repoUrl: 'https://github.com/louislam/uptime-kuma' },
|
||||
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
|
||||
{ id: 'netbird', title: 'NetBird', version: '0.71.2', description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.', icon: '/assets/img/app-icons/netbird.svg', author: 'NetBird', dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0', repoUrl: 'https://github.com/netbirdio/netbird' },
|
||||
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
||||
{ id: 'fedimint', title: 'Fedimint Guardian', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
|
||||
{ id: 'fedimint-clientd', title: 'Fedimint Client', version: '0.8.0', description: 'Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fmcd:0.8.1`, repoUrl: 'https://github.com/minmoto/fmcd' },
|
||||
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
||||
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
|
||||
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
|
||||
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
|
||||
]
|
||||
}
|
||||
|
||||
// Only PRIMARY containers trigger "installed" status.
|
||||
// Supporting containers (DBs, caches, workers) do NOT — having only a DB
|
||||
// without the main app should not mark the app as installed in the UI.
|
||||
export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
'fedimint-clientd': ['fedimint-clientd'],
|
||||
electrumx: ['electrumx'],
|
||||
grafana: ['grafana'],
|
||||
jellyfin: ['jellyfin'],
|
||||
vaultwarden: ['vaultwarden'],
|
||||
searxng: ['searxng'],
|
||||
homeassistant: ['homeassistant'],
|
||||
photoprism: ['photoprism'],
|
||||
lnd: ['lnd'],
|
||||
filebrowser: ['filebrowser'],
|
||||
tailscale: ['tailscale'],
|
||||
netbird: ['netbird'],
|
||||
ollama: ['ollama'],
|
||||
indeedhub: ['indeedhub'],
|
||||
botfights: ['botfights'],
|
||||
}
|
||||
|
||||
// Featured apps shown at the top of the App Store.
|
||||
// The first entry with a `banner` is displayed as a full-width hero banner.
|
||||
// To change the featured app, move the desired entry to position 0 and set its `banner`.
|
||||
export const FEATURED_DEFINITIONS: {
|
||||
id: string
|
||||
desc: string
|
||||
tag: string
|
||||
banner?: string // path to banner image (shown as full-width hero)
|
||||
}[] = [
|
||||
{
|
||||
id: 'indeedhub',
|
||||
desc: 'Bitcoin documentaries with Nostr identity. God Bless Bitcoin, The Bitcoin Psyop, and more — streaming from your own node. No accounts, no subscriptions. Sign in with Nostr.',
|
||||
tag: 'NOSTR IDENTITY // YOUR NODE',
|
||||
banner: '/assets/img/featured/indeedhub-banner.jpg',
|
||||
},
|
||||
{
|
||||
id: 'bitcoin-knots',
|
||||
desc: 'The foundation of sovereignty. Run a full Bitcoin node to validate every transaction yourself. No trusted third parties. No asking permission. Your node enforces the consensus rules that protect your wealth. Don\'t trust — verify.',
|
||||
tag: 'FULL VALIDATION // ZERO TRUST',
|
||||
},
|
||||
{
|
||||
id: 'bitcoin-core',
|
||||
desc: 'The reference Bitcoin implementation. Same full-node guarantees as Knots, tracking upstream releases from the Bitcoin Core maintainers. Pick this if you\'d rather run mainline Bitcoin Core than Knots — both validate every block themselves.',
|
||||
tag: 'REFERENCE CLIENT // ZERO TRUST',
|
||||
},
|
||||
{
|
||||
id: 'lnd',
|
||||
desc: 'Lightning-fast payments over the Lightning Network. Open channels, route transactions, and earn routing fees — all from your sovereign node. Instant settlement. Near-zero fees. The future of money, running on your hardware.',
|
||||
tag: 'INSTANT SETTLEMENT // YOUR CHANNELS',
|
||||
},
|
||||
{
|
||||
id: 'btcpay-server',
|
||||
desc: 'Accept Bitcoin payments without intermediaries. No fees to payment processors. No KYC. No permission needed. Your commerce, your terms. Self-hosted payment infrastructure that makes you truly independent.',
|
||||
tag: 'NO INTERMEDIARIES // NO KYC',
|
||||
},
|
||||
{
|
||||
id: 'vaultwarden',
|
||||
desc: 'Your passwords belong to you. Self-hosted password vault with full Bitwarden compatibility. Zero-knowledge encryption means even you can\'t see your passwords without your master key. No cloud required — your secrets, your server.',
|
||||
tag: 'ZERO KNOWLEDGE // SELF-HOSTED',
|
||||
},
|
||||
]
|
||||
|
||||
export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
if (app.category) return app.category
|
||||
const id = app.id.toLowerCase()
|
||||
const title = app.title?.toLowerCase() || ''
|
||||
const description = (typeof app.description === 'string' ? app.description : app.description?.short ?? '').toLowerCase()
|
||||
const combined = `${id} ${title} ${description}`
|
||||
|
||||
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
|
||||
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
||||
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
|
||||
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
|
||||
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
|
||||
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('netbird') || id.includes('proxy') || id.includes('dns') || id.includes('tor') || combined.includes('network')) return 'networking'
|
||||
if (id.includes('matrix') || id.includes('mastodon') || id.includes('chat') || id.includes('social') || combined.includes('messaging')) return 'community'
|
||||
return 'other'
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { MarketplaceAppInfo } from '@/composables/useMarketplaceApp'
|
||||
|
||||
/** Container config that can be passed from the remote catalog to the backend
|
||||
* for apps not hardcoded in config.rs */
|
||||
export interface ContainerConfig {
|
||||
ports?: string[]
|
||||
volumes?: string[]
|
||||
env?: string[]
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
|
||||
export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
|
||||
id: string
|
||||
trustScore?: number
|
||||
trustTier?: string
|
||||
relayCount?: number
|
||||
containerConfig?: ContainerConfig
|
||||
requires?: string[]
|
||||
tier?: string
|
||||
}
|
||||
|
||||
export type FeaturedApp = MarketplaceApp & {
|
||||
featuredDescription: string
|
||||
privacyTag: string
|
||||
bannerImage?: string
|
||||
}
|
||||
|
||||
export interface InstallProgress {
|
||||
id: string
|
||||
title: string
|
||||
status: 'downloading' | 'installing' | 'starting' | 'complete' | 'error'
|
||||
progress: number
|
||||
message: string
|
||||
attempt: number
|
||||
}
|
||||
|
||||
export interface CategoryDef {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
Reference in New Issue
Block a user