feat(ui): add private phone setup goal and shared hardware listings
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -192,6 +192,6 @@ onBeforeUnmount(() => {
|
||||
.device-model__caption { position: absolute; inset: auto 0 0; display: flex; justify-content: center; align-items: center; gap: 14px; color: rgba(255,255,255,.4); font-size: 11px; }
|
||||
.device-model__caption button { padding: 4px 9px; color: rgba(255,255,255,.65); border: 1px solid rgba(255,255,255,.12); border-radius: 20px; background: transparent; cursor: pointer; }
|
||||
.device-model__counter { color: rgba(255,255,255,.3); font-variant-numeric: tabular-nums; }
|
||||
.device-model--viewport { height: 330px; }
|
||||
.device-model--viewport { height: 290px; }
|
||||
@media (min-width: 1024px) { .device-model--viewport { height: var(--device-height); min-height: 260px; } }
|
||||
</style>
|
||||
|
||||
@@ -94,6 +94,7 @@ function goalIcon(icon: string): string {
|
||||
lightning: '⚡',
|
||||
identity: '🔑',
|
||||
backup: '💾',
|
||||
phone: '📱',
|
||||
}
|
||||
return icons[icon] || '📦'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const props = withDefaults(defineProps<{ expanded?: boolean; kind?: 'router' | 'phone' }>(), { expanded: false, kind: 'router' })
|
||||
const emit = defineEmits<{ open: []; close: [] }>()
|
||||
|
||||
interface HardwareListing {
|
||||
id: string
|
||||
pubkey: string
|
||||
title: string
|
||||
summary: string
|
||||
content: string
|
||||
location: string
|
||||
status: string
|
||||
image: string
|
||||
price?: { amount: string; currency: string; frequency?: string }
|
||||
relay: string
|
||||
}
|
||||
|
||||
const relayUrls = ref<string[]>([])
|
||||
const DEV_ROUTER_LISTINGS: HardwareListing[] = [
|
||||
{ id: 'dev-beryl-ax', pubkey: 'archipelago-demo-seller', title: 'GL.iNet Beryl AX (GL-MT3000)', summary: 'Wi‑Fi 6 travel router with OpenWrt, WireGuard and mesh-ready firmware.', content: '', location: 'London, UK', status: 'active', image: '/assets/img/router-mockups/beryl-ax.webp', price: { amount: '89', currency: 'EUR' }, relay: 'development mock' },
|
||||
{ id: 'dev-flint-2', pubkey: 'openwrt-demo-seller', title: 'GL.iNet Flint 2', summary: 'Dual 2.5G Ethernet OpenWrt router for a home gateway or node controller.', content: '', location: 'Berlin, DE', status: 'active', image: '/assets/img/router-mockups/flint-2.webp', price: { amount: '159', currency: 'EUR' }, relay: 'development mock' },
|
||||
{ id: 'dev-nano-pi', pubkey: 'community-demo-seller', title: 'NanoPi R5S OpenWrt gateway', summary: 'Compact RK3568 gateway with three Ethernet ports and community support.', content: '', location: 'Amsterdam, NL', status: 'active', image: '/assets/img/router-mockups/nanopi-r5s.webp', price: { amount: '74', currency: 'EUR' }, relay: 'development mock' },
|
||||
]
|
||||
const DEV_PHONE_LISTINGS: HardwareListing[] = [
|
||||
{ id: 'dev-pixel-9', pubkey: 'privacy-demo-seller', title: 'Pixel 9 · GrapheneOS ready', summary: 'Unlocked Pixel with a long update runway and a fresh install path.', content: '', location: 'London, UK', status: 'active', image: '/assets/img/phone-mockups/pixel-9.webp', price: { amount: '499', currency: 'EUR' }, relay: 'development mock' },
|
||||
{ id: 'dev-pixel-9a', pubkey: 'mobile-demo-seller', title: 'Pixel 9a · unlocked', summary: 'Compact everyday Pixel. Check the bootloader can be unlocked before buying.', content: '', location: 'Berlin, DE', status: 'active', image: '/assets/img/phone-mockups/pixel-9a.webp', price: { amount: '339', currency: 'EUR' }, relay: 'development mock' },
|
||||
{ id: 'dev-pixel-10', pubkey: 'phone-demo-seller', title: 'Pixel 10 · GrapheneOS ready', summary: 'A newer Pixel for private, independent mobile computing.', content: '', location: 'Amsterdam, NL', status: 'active', image: '/assets/img/phone-mockups/pixel-10.webp', price: { amount: '699', currency: 'EUR' }, relay: 'development mock' },
|
||||
]
|
||||
const devListings = computed(() => props.kind === 'phone' ? DEV_PHONE_LISTINGS : DEV_ROUTER_LISTINGS)
|
||||
const topicTags = computed(() => props.kind === 'phone' ? ['pixel', 'grapheneos', 'privacy-phone'] : ['openwrt', 'router', 'gl-inet', 'beryl', 'flint'])
|
||||
const open = ref(false)
|
||||
const carousel = ref<HTMLElement>()
|
||||
const listings = ref<HardwareListing[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
let requestId = 0
|
||||
let sockets: WebSocket[] = []
|
||||
|
||||
function tag(event: { tags?: string[][] }, name: string): string {
|
||||
return event.tags?.find(item => item[0] === name)?.[1]?.trim() ?? ''
|
||||
}
|
||||
function imageTag(event: { tags?: string[][] }): string {
|
||||
return event.tags?.find(item => item[0] === 'image')?.[1]?.trim() ?? ''
|
||||
}
|
||||
function matchesHardware(item: { title: string; summary: string; content: string; tags?: string[][] }) {
|
||||
const tags = item.tags?.flat().join(' ') ?? ''
|
||||
const text = `${item.title} ${item.summary} ${item.content} ${tags}`
|
||||
return props.kind === 'phone'
|
||||
? /\bpixel\b|grapheneos/i.test(text)
|
||||
: /openwrt|gl[- .]?inet|beryl|flint|nanopi|gl-mt3000|gl-mt6000/i.test(text)
|
||||
}
|
||||
function parse(event: {
|
||||
id?: string; pubkey?: string; content?: string; tags?: string[][]
|
||||
}, relay: string): HardwareListing | null {
|
||||
if (!event.id || !event.pubkey || !matchesHardware({ title: tag(event, 'title'), summary: tag(event, 'summary'), content: event.content ?? '', tags: event.tags })) return null
|
||||
const price = event.tags?.find(item => item[0] === 'price')
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
title: tag(event, 'title') || (props.kind === 'phone' ? 'Pixel phone' : 'OpenWrt router'),
|
||||
summary: tag(event, 'summary') || (event.content ?? '').split('\n')[0]?.slice(0, 150) || 'Hardware listing',
|
||||
content: event.content ?? '',
|
||||
location: tag(event, 'location'),
|
||||
status: tag(event, 'status') || 'active',
|
||||
image: imageTag(event),
|
||||
price: price?.[1] && price[2] ? { amount: price[1], currency: price[2], frequency: price[3] } : undefined,
|
||||
relay,
|
||||
}
|
||||
}
|
||||
function closeSockets() {
|
||||
sockets.forEach(socket => socket.close())
|
||||
sockets = []
|
||||
}
|
||||
async function search() {
|
||||
const run = ++requestId
|
||||
closeSockets(); loading.value = true; error.value = ''
|
||||
try {
|
||||
const configured = await rpcClient.call<{ relays?: Array<{ url?: string; enabled?: boolean }> }>({ method: 'nostr.list-relays', params: {} })
|
||||
relayUrls.value = (configured.relays ?? [])
|
||||
.filter(relay => relay.enabled !== false && typeof relay.url === 'string' && /^wss?:\/\//.test(relay.url))
|
||||
.map(relay => relay.url!.replace(/\/$/, ''))
|
||||
} catch {
|
||||
relayUrls.value = []
|
||||
}
|
||||
if (run !== requestId) return
|
||||
if (!relayUrls.value.length) {
|
||||
if (import.meta.env.DEV) {
|
||||
listings.value = devListings.value
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = false
|
||||
error.value = 'No Archipelago Nostr relays are configured. Add a relay in Settings → Nostr Relays and try again.'
|
||||
return
|
||||
}
|
||||
const found = new Map<string, HardwareListing>()
|
||||
await Promise.all(relayUrls.value.map(relay => new Promise<void>(resolve => {
|
||||
let settled = false
|
||||
const finish = () => { if (!settled) { settled = true; resolve() } }
|
||||
let socket: WebSocket
|
||||
try { socket = new WebSocket(relay) } catch { finish(); return }
|
||||
sockets.push(socket)
|
||||
const timer = window.setTimeout(() => { socket.close(); finish() }, 8000)
|
||||
socket.addEventListener('open', () => {
|
||||
socket.send(JSON.stringify(['REQ', `archy-${props.kind}-${run}`, {
|
||||
kinds: [30402],
|
||||
'#t': topicTags.value,
|
||||
limit: 100,
|
||||
}, {
|
||||
kinds: [30402],
|
||||
limit: 200,
|
||||
}]))
|
||||
})
|
||||
socket.addEventListener('message', message => {
|
||||
try {
|
||||
const packet = JSON.parse(String(message.data)) as [string, string, { id?: string; pubkey?: string; content?: string; tags?: string[][] }]
|
||||
if (packet[0] === 'EVENT') {
|
||||
const item = parse(packet[2], relay)
|
||||
if (item && item.status !== 'sold') found.set(item.id, item)
|
||||
}
|
||||
if (packet[0] === 'EOSE') { window.clearTimeout(timer); socket.close(); finish() }
|
||||
} catch { /* Ignore malformed relay frames. */ }
|
||||
})
|
||||
socket.addEventListener('error', () => { window.clearTimeout(timer); finish() })
|
||||
socket.addEventListener('close', () => { window.clearTimeout(timer); finish() })
|
||||
})))
|
||||
if (run !== requestId) return
|
||||
listings.value = found.size ? [...found.values()] : import.meta.env.DEV ? devListings.value : []
|
||||
loading.value = false
|
||||
if (!listings.value.length) error.value = `No active ${props.kind === 'phone' ? 'Pixel phone' : 'OpenWrt router'} listings found on your configured relays.`
|
||||
}
|
||||
function openShop() {
|
||||
open.value = true
|
||||
if (import.meta.env.DEV) listings.value = devListings.value
|
||||
emit('open')
|
||||
void search()
|
||||
}
|
||||
function closeShop() { requestId++; open.value = false; closeSockets(); emit('close') }
|
||||
function moveCarousel(direction: number) {
|
||||
const list = carousel.value
|
||||
const card = list?.querySelector<HTMLElement>('.router-marketplace__card')
|
||||
if (list && card) list.scrollBy({ left: direction * (card.offsetWidth + 16), behavior: 'smooth' })
|
||||
}
|
||||
function formatPrice(item: HardwareListing) {
|
||||
if (!item.price) return 'Price on request'
|
||||
return `${item.price.amount} ${item.price.currency.toUpperCase()}${item.price.frequency ? ` / ${item.price.frequency}` : ''}`
|
||||
}
|
||||
onBeforeUnmount(() => { requestId++; closeSockets() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="router-marketplace lg:mt-0" :class="{ 'router-marketplace--expanded': props.expanded }" aria-labelledby="router-marketplace-title">
|
||||
<div class="glass-card p-5 md:p-6">
|
||||
<template v-if="!open">
|
||||
<p class="text-xs uppercase tracking-[.18em] text-white/40">{{ props.kind === 'phone' ? 'Private mobile' : 'OpenWrt gateway' }}</p>
|
||||
<h2 id="router-marketplace-title" class="text-base font-semibold text-white mt-1">{{ props.kind === 'phone' ? 'Ready for a phone that answers to you?' : 'Not got an OpenWrt router?' }}</h2>
|
||||
<p class="text-sm text-white/55 mt-2 max-w-2xl">{{ props.kind === 'phone' ? 'Choose an unlockable Pixel. Install GrapheneOS. Lock the bootloader for verified boot.' : 'Browse community-published router listings from Nostr marketplaces. We’ll add Archipelago checkout after discovery is in place.' }}</p>
|
||||
<button type="button" class="glass-button router-marketplace__cta mt-4" @click="openShop">
|
||||
{{ props.kind === 'phone' ? 'Buy phone' : 'Buy router' }}
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<h2 id="router-marketplace-title" class="text-base font-semibold text-white">{{ props.kind === 'phone' ? 'Pixel phones for sale' : 'OpenWrt routers for sale' }}</h2>
|
||||
<p class="text-xs text-white/50 mt-1">{{ listings[0]?.relay === 'development mock' ? 'Development preview · conceptual images and sample prices' : 'Live NIP-99 listings from Archipelago relays.' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="router-marketplace__nav" :aria-label="`Previous ${props.kind} listing`" @click="moveCarousel(-1)">‹</button>
|
||||
<button type="button" class="router-marketplace__nav" :aria-label="`Next ${props.kind} listing`" @click="moveCarousel(1)">›</button>
|
||||
<button type="button" class="text-xs text-white/45 hover:text-white ml-2" @click="closeShop">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading && !listings.length" class="sr-only" aria-live="polite">Loading {{ props.kind }} listings</div>
|
||||
<div v-else-if="error && !listings.length" class="text-sm text-white/50 py-5 text-center">{{ error }}</div>
|
||||
<div v-else ref="carousel" class="router-marketplace__results" tabindex="0" :aria-label="`${props.kind === 'phone' ? 'Phone' : 'Router'} listings carousel`">
|
||||
<article v-for="item in listings" :key="item.id" class="router-marketplace__card">
|
||||
<img v-if="item.image" :src="item.image" :alt="item.title" class="router-marketplace__image" loading="lazy" />
|
||||
<div v-else class="router-marketplace__image router-marketplace__image--empty" aria-hidden="true">⌁</div>
|
||||
<div class="router-marketplace__details">
|
||||
<h3 class="text-sm font-medium text-white truncate">{{ item.title }}</h3>
|
||||
<p class="text-xs text-white/55 mt-1 line-clamp-1">{{ item.summary }}</p>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-3 text-[11px] text-white/40">
|
||||
<span v-if="item.location">{{ item.location }}</span>
|
||||
<span>seller {{ item.pubkey.slice(0, 8) }}…</span>
|
||||
</div>
|
||||
<div class="router-marketplace__footer">
|
||||
<strong class="router-marketplace__price">{{ formatPrice(item) }}</strong>
|
||||
<div class="router-marketplace__actions">
|
||||
<button type="button" class="router-marketplace__action" disabled title="Listing details are next">View</button>
|
||||
<button type="button" class="router-marketplace__action router-marketplace__action--buy" disabled title="Checkout is the next step">Buy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.router-marketplace { margin-top: 16px; }
|
||||
.router-marketplace--expanded { grid-column: 1 / -1; width: 100%; min-width: 0; height: 100%; min-height: 0; margin-top: 0; }
|
||||
.router-marketplace--expanded > .glass-card { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 14px; }
|
||||
.router-marketplace--expanded .router-marketplace__results { flex: 1 1 0; min-height: 0; }
|
||||
.router-marketplace__cta { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.router-marketplace__nav { display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; color: rgba(255,255,255,.72); font-size: 18px; line-height: 1; }
|
||||
.router-marketplace__nav:hover { background: rgba(255,255,255,.09); }
|
||||
@media (max-width: 1023px) {
|
||||
.router-marketplace__cta { width: 100%; justify-content: center; }
|
||||
/* The dashboard's fixed mobile Back bar must not cover card actions. */
|
||||
.router-marketplace--expanded > .glass-card { padding-bottom: 64px; }
|
||||
}
|
||||
.router-marketplace__results { display: flex; align-items: stretch; gap: 16px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none; mask-image: linear-gradient(to right, #000 0%, #000 calc(100% - 52px), transparent 100%); }
|
||||
.router-marketplace__results::-webkit-scrollbar { display: none; }
|
||||
.router-marketplace__card { display: flex; flex: 0 0 88%; scroll-snap-align: start; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; padding: 12px; border: 1px solid rgba(255,255,255,.09); border-radius: 11px; background: rgba(255,255,255,.035); }
|
||||
.router-marketplace__details { display: flex; flex: 0 0 auto; min-width: 0; flex-direction: column; }
|
||||
.router-marketplace__footer { margin-top: auto; padding-top: 8px; }
|
||||
.router-marketplace__price { display: block; color: rgba(180,244,235,.9); font-size: 18px; }
|
||||
.router-marketplace__actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.router-marketplace__action { flex: 1; padding: 8px 12px; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; color: rgba(255,255,255,.55); font-size: 12px; }
|
||||
.router-marketplace__action--buy { border-color: rgba(118,230,191,.35); color: rgba(180,244,235,.9); }
|
||||
@media (min-width: 768px) {
|
||||
.router-marketplace__card { flex-basis: min(360px, 44%); }
|
||||
}
|
||||
.router-marketplace__image { width: 100%; height: 0; min-height: 0; flex: 1 1 0; object-fit: contain; border-radius: 8px; background: linear-gradient(135deg, rgba(112,214,205,.18), rgba(255,255,255,.04)); }
|
||||
.router-marketplace__image--empty { display: grid; place-items: center; color: rgba(121,205,215,.6); font-size: 34px; }
|
||||
</style>
|
||||
@@ -1,214 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const props = withDefaults(defineProps<{ expanded?: boolean }>(), { expanded: false })
|
||||
const emit = defineEmits<{ open: []; close: [] }>()
|
||||
|
||||
interface RouterListing {
|
||||
id: string
|
||||
pubkey: string
|
||||
title: string
|
||||
summary: string
|
||||
content: string
|
||||
location: string
|
||||
status: string
|
||||
image: string
|
||||
price?: { amount: string; currency: string; frequency?: string }
|
||||
relay: string
|
||||
}
|
||||
|
||||
const relayUrls = ref<string[]>([])
|
||||
const DEV_LISTINGS: RouterListing[] = [
|
||||
{ id: 'dev-beryl-ax', pubkey: 'archipelago-demo-seller', title: 'GL.iNet Beryl AX (GL-MT3000)', summary: 'Wi‑Fi 6 travel router with OpenWrt, WireGuard and mesh-ready firmware.', content: '', location: 'London, UK', status: 'active', image: '', price: { amount: '€89', currency: 'EUR' }, relay: 'development mock' },
|
||||
{ id: 'dev-flint-2', pubkey: 'openwrt-demo-seller', title: 'GL.iNet Flint 2', summary: 'Dual 2.5G Ethernet OpenWrt router for a home gateway or node controller.', content: '', location: 'Berlin, DE', status: 'active', image: '', price: { amount: '€159', currency: 'EUR' }, relay: 'development mock' },
|
||||
{ id: 'dev-nano-pi', pubkey: 'community-demo-seller', title: 'NanoPi R5S OpenWrt gateway', summary: 'Compact RK3568 gateway with three Ethernet ports and community support.', content: '', location: 'Amsterdam, NL', status: 'active', image: '', price: { amount: '€74', currency: 'EUR' }, relay: 'development mock' },
|
||||
]
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const listings = ref<RouterListing[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
let requestId = 0
|
||||
let sockets: WebSocket[] = []
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = query.value.trim().toLowerCase()
|
||||
if (!term) return listings.value
|
||||
return listings.value.filter(item => `${item.title} ${item.summary} ${item.content} ${item.location}`.toLowerCase().includes(term))
|
||||
})
|
||||
|
||||
function tag(event: { tags?: string[][] }, name: string): string {
|
||||
return event.tags?.find(item => item[0] === name)?.[1]?.trim() ?? ''
|
||||
}
|
||||
function imageTag(event: { tags?: string[][] }): string {
|
||||
return event.tags?.find(item => item[0] === 'image')?.[1]?.trim() ?? ''
|
||||
}
|
||||
function isRouter(item: { title: string; summary: string; content: string; location?: string; tags?: string[][] }) {
|
||||
const tags = item.tags?.flat().join(' ') ?? ''
|
||||
return /router|openwrt|wifi|gateway|gl[- ]?inet|travel router|mesh/i.test(`${item.title} ${item.summary} ${item.content} ${item.location ?? ''} ${tags}`)
|
||||
}
|
||||
function parse(event: {
|
||||
id?: string; pubkey?: string; content?: string; tags?: string[][]
|
||||
}, relay: string): RouterListing | null {
|
||||
if (!event.id || !event.pubkey || !isRouter({ title: tag(event, 'title'), summary: tag(event, 'summary'), content: event.content ?? '', location: '', tags: event.tags })) return null
|
||||
const price = event.tags?.find(item => item[0] === 'price')
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
title: tag(event, 'title') || 'OpenWrt router',
|
||||
summary: tag(event, 'summary') || (event.content ?? '').split('\n')[0]?.slice(0, 150) || 'Router listing',
|
||||
content: event.content ?? '',
|
||||
location: tag(event, 'location'),
|
||||
status: tag(event, 'status') || 'active',
|
||||
image: imageTag(event),
|
||||
price: price?.[1] && price[2] ? { amount: price[1], currency: price[2], frequency: price[3] } : undefined,
|
||||
relay,
|
||||
}
|
||||
}
|
||||
function closeSockets() {
|
||||
sockets.forEach(socket => socket.close())
|
||||
sockets = []
|
||||
}
|
||||
async function search() {
|
||||
const run = ++requestId
|
||||
closeSockets(); loading.value = true; error.value = ''
|
||||
try {
|
||||
const configured = await rpcClient.call<{ relays?: Array<{ url?: string; enabled?: boolean }> }>({ method: 'nostr.list-relays', params: {} })
|
||||
relayUrls.value = (configured.relays ?? [])
|
||||
.filter(relay => relay.enabled !== false && typeof relay.url === 'string' && /^wss?:\/\//.test(relay.url))
|
||||
.map(relay => relay.url!.replace(/\/$/, ''))
|
||||
} catch {
|
||||
relayUrls.value = []
|
||||
}
|
||||
if (!relayUrls.value.length) {
|
||||
if (import.meta.env.DEV) {
|
||||
listings.value = DEV_LISTINGS
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = false
|
||||
error.value = 'No Archipelago Nostr relays are configured. Add a relay in Settings → Nostr Relays and try again.'
|
||||
return
|
||||
}
|
||||
const found = new Map<string, RouterListing>()
|
||||
await Promise.all(relayUrls.value.map(relay => new Promise<void>(resolve => {
|
||||
let settled = false
|
||||
const finish = () => { if (!settled) { settled = true; resolve() } }
|
||||
let socket: WebSocket
|
||||
try { socket = new WebSocket(relay) } catch { finish(); return }
|
||||
sockets.push(socket)
|
||||
const timer = window.setTimeout(() => { socket.close(); finish() }, 8000)
|
||||
socket.addEventListener('open', () => {
|
||||
socket.send(JSON.stringify(['REQ', `archy-router-${run}`, {
|
||||
kinds: [30402],
|
||||
limit: 200,
|
||||
}]))
|
||||
})
|
||||
socket.addEventListener('message', message => {
|
||||
try {
|
||||
const packet = JSON.parse(String(message.data)) as [string, string, { id?: string; pubkey?: string; content?: string; tags?: string[][] }]
|
||||
if (packet[0] === 'EVENT') {
|
||||
const item = parse(packet[2], relay)
|
||||
if (item && item.status !== 'sold') found.set(item.id, item)
|
||||
}
|
||||
if (packet[0] === 'EOSE') { window.clearTimeout(timer); socket.close(); finish() }
|
||||
} catch { /* Ignore malformed relay frames. */ }
|
||||
})
|
||||
socket.addEventListener('error', () => { window.clearTimeout(timer); finish() })
|
||||
socket.addEventListener('close', () => { window.clearTimeout(timer); finish() })
|
||||
})))
|
||||
if (run !== requestId) return
|
||||
listings.value = [...found.values()]
|
||||
if (!listings.value.length && import.meta.env.DEV) listings.value = DEV_LISTINGS
|
||||
loading.value = false
|
||||
if (!listings.value.length) error.value = 'No active OpenWrt router listings answered from the configured public relays.'
|
||||
}
|
||||
function openShop() {
|
||||
open.value = true
|
||||
query.value = 'openwrt'
|
||||
if (import.meta.env.DEV) listings.value = DEV_LISTINGS
|
||||
emit('open')
|
||||
void search()
|
||||
}
|
||||
function closeShop() { open.value = false; closeSockets(); emit('close') }
|
||||
function formatPrice(item: RouterListing) {
|
||||
if (!item.price) return 'Price on request'
|
||||
return `${item.price.amount} ${item.price.currency.toUpperCase()}${item.price.frequency ? ` / ${item.price.frequency}` : ''}`
|
||||
}
|
||||
watch(query, () => { if (open.value && query.value.trim().length >= 3) void search() })
|
||||
onBeforeUnmount(closeSockets)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="router-marketplace lg:mt-0" :class="{ 'router-marketplace--expanded': props.expanded }" aria-labelledby="router-marketplace-title">
|
||||
<div class="glass-card p-5 md:p-6">
|
||||
<template v-if="!open">
|
||||
<p class="text-xs uppercase tracking-[.18em] text-white/40">OpenWrt gateway</p>
|
||||
<h2 id="router-marketplace-title" class="text-base font-semibold text-white mt-1">Not got an OpenWrt router?</h2>
|
||||
<p class="text-sm text-white/55 mt-2 max-w-2xl">Browse community-published router listings from Nostr marketplaces. We’ll add Archipelago checkout after discovery is in place.</p>
|
||||
<button type="button" class="glass-button router-marketplace__cta mt-4" @click="openShop">
|
||||
Buy router
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[.18em] text-white/40">Nostr classifieds</p>
|
||||
<h2 id="router-marketplace-title" class="text-base font-semibold text-white mt-1">OpenWrt routers for sale</h2>
|
||||
<p class="text-xs text-white/50 mt-1">Live NIP-99 listings from Archipelago relays.</p>
|
||||
</div>
|
||||
<button type="button" class="text-xs text-white/45 hover:text-white" @click="closeShop">Close</button>
|
||||
</div>
|
||||
<div v-if="loading && !filtered.length" class="sr-only" aria-live="polite">Loading router listings</div>
|
||||
<div v-else-if="error && !filtered.length" class="text-sm text-white/50 py-5 text-center">{{ error }}</div>
|
||||
<div v-else class="router-marketplace__results" tabindex="0" aria-label="Router listings carousel">
|
||||
<article v-for="item in filtered" :key="item.id" class="router-marketplace__card">
|
||||
<img v-if="item.image" :src="item.image" :alt="item.title" class="router-marketplace__image" loading="lazy" />
|
||||
<div v-else class="router-marketplace__image router-marketplace__image--empty" aria-hidden="true">⌁</div>
|
||||
<div class="router-marketplace__details">
|
||||
<h3 class="text-sm font-medium text-white truncate">{{ item.title }}</h3>
|
||||
<p class="text-xs text-white/55 mt-1 line-clamp-2">{{ item.summary }}</p>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-3 text-[11px] text-white/40">
|
||||
<span v-if="item.location">{{ item.location }}</span>
|
||||
<span>seller {{ item.pubkey.slice(0, 8) }}…</span>
|
||||
</div>
|
||||
<div class="router-marketplace__footer">
|
||||
<strong class="router-marketplace__price">{{ formatPrice(item) }}</strong>
|
||||
<div class="router-marketplace__actions">
|
||||
<button type="button" class="router-marketplace__action" disabled title="Listing details are next">View</button>
|
||||
<button type="button" class="router-marketplace__action router-marketplace__action--buy" disabled title="Checkout is the next step">Buy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.router-marketplace { margin-top: 16px; }
|
||||
.router-marketplace--expanded { grid-column: 1 / -1; height: 100%; margin-top: 0; }
|
||||
.router-marketplace--expanded > .glass-card { height: 100%; display: flex; flex-direction: column; }
|
||||
.router-marketplace--expanded .router-marketplace__results { flex: 1; min-height: 0; }
|
||||
.router-marketplace__cta { display: inline-flex; align-items: center; gap: 10px; }
|
||||
@media (max-width: 1023px) {
|
||||
.router-marketplace__cta { width: 100%; justify-content: center; }
|
||||
}
|
||||
.router-marketplace__results { display: flex; gap: 16px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: thin; padding-bottom: 4px; }
|
||||
.router-marketplace__card { display: flex; flex: 0 0 100%; scroll-snap-align: start; flex-direction: column; gap: 12px; min-width: 0; height: 100%; padding: 12px; border: 1px solid rgba(255,255,255,.09); border-radius: 11px; background: rgba(255,255,255,.035); }
|
||||
.router-marketplace__details { display: flex; flex: 1; min-height: 0; flex-direction: column; }
|
||||
.router-marketplace__footer { margin-top: auto; padding-top: 18px; }
|
||||
.router-marketplace__price { display: block; color: rgba(180,244,235,.9); font-size: 18px; }
|
||||
.router-marketplace__actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.router-marketplace__action { flex: 1; padding: 8px 12px; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; color: rgba(255,255,255,.55); font-size: 12px; }
|
||||
.router-marketplace__action--buy { border-color: rgba(118,230,191,.35); color: rgba(180,244,235,.9); }
|
||||
@media (min-width: 768px) {
|
||||
.router-marketplace__card { flex-basis: calc((100% - 16px) / 2); }
|
||||
}
|
||||
.router-marketplace__image { width: 100%; height: clamp(150px, 28vh, 260px); object-fit: cover; border-radius: 8px; flex-shrink: 0; background: linear-gradient(135deg, rgba(112,214,205,.18), rgba(255,255,255,.04)); }
|
||||
.router-marketplace__image--empty { display: grid; place-items: center; color: rgba(121,205,215,.6); font-size: 34px; }
|
||||
</style>
|
||||
@@ -67,6 +67,13 @@ export const GOALS: GoalDefinition[] = [
|
||||
route: '/dashboard/setup/external-access', estimatedTime: 'Guided setup', difficulty: 'beginner',
|
||||
steps: [{ id: 'external-access', title: 'Configure access', description: 'Choose and verify each connection.', action: 'configure', isAutomatic: false }],
|
||||
},
|
||||
{
|
||||
id: 'non-surveillance-phone', title: 'Get a non surveillance phone',
|
||||
subtitle: 'Choose an unlockable Pixel and take control with GrapheneOS',
|
||||
icon: 'phone', category: 'identity', requiredApps: [],
|
||||
route: '/dashboard/setup/non-surveillance-phone', estimatedTime: 'Browse phones', difficulty: 'beginner',
|
||||
steps: [{ id: 'choose-private-phone', title: 'Choose a compatible Pixel', description: 'Check that the bootloader can be unlocked, then install GrapheneOS and lock it again for verified boot.', action: 'info', isAutomatic: false }],
|
||||
},
|
||||
{
|
||||
id: 'publish-website', title: 'Publish a website',
|
||||
subtitle: 'Create a website on your node and choose where to publish it',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.j
|
||||
// Original, procedural display models. Dimensions/layouts follow the existing
|
||||
// device illustrations and manufacturer references listed in device-models.md.
|
||||
// These are recognition aids, not mechanical CAD or revision-specific pinouts.
|
||||
type Kind = 'board' | 'echo' | 'deck' | 'card' | 'case' | 'router'
|
||||
type Kind = 'board' | 'echo' | 'deck' | 'card' | 'case' | 'router' | 'phone'
|
||||
interface DeviceSpec {
|
||||
kind: Kind; name: string; w: number; h: number; d: number; color: number
|
||||
screen?: 'oled' | 'paper'; antenna?: boolean; battery?: boolean; pins?: number
|
||||
@@ -36,6 +36,7 @@ export const deviceSpecs: Record<string, DeviceSpec> = {
|
||||
'rak4631_case': { kind: 'case', name: 'RAK WisBlock', w: 1.4, h: 2.3, d: .55, color: 0xd5dad5, screen: 'oled', antenna: true },
|
||||
'unknown-new': { kind: 'case', name: 'LoRa mesh radio', w: 1.8, h: 2.6, d: .5, color: 0x182623, screen: 'oled' },
|
||||
'beryl-ax': { kind: 'router', name: 'Beryl AX · GL-MT3000', w: 3, h: .8, d: 2.05, color: 0x171b20 },
|
||||
'pixel-graphene': { kind: 'phone', name: 'Pixel · GrapheneOS', w: 1.82, h: 3.7, d: .26, color: 0x24282c },
|
||||
}
|
||||
|
||||
export function createDeviceModel(id: string) {
|
||||
@@ -157,6 +158,41 @@ export function createDeviceModel(id: string) {
|
||||
box(.02, .05, .05, -1.51, .14, -.77, dark)
|
||||
const underside = label('GL-MT3000 / BERYL AX', 1.25, .15, 0, -.413, 0)
|
||||
underside.rotation.x = Math.PI / 2
|
||||
} else if (spec.kind === 'phone') {
|
||||
// Pixel-inspired handset: metal edge, side keys, rear camera bar and a
|
||||
// conceptual GrapheneOS verified-boot display. This is not device CAD.
|
||||
box(w + .035, h + .035, d + .025, 0, 0, 0, silver, .22)
|
||||
box(w, h, d, 0, 0, 0, body, .21)
|
||||
box(w - .08, h - .09, .025, 0, 0, d / 2 + .018, black, .19)
|
||||
box(w - .15, h - .18, .008, 0, 0, d / 2 + .036, glass, .15)
|
||||
const bootCanvas = document.createElement('canvas')
|
||||
bootCanvas.width = 512; bootCanvas.height = 1024
|
||||
const boot = bootCanvas.getContext('2d')!
|
||||
const glow = boot.createLinearGradient(0, 0, 512, 1024)
|
||||
glow.addColorStop(0, '#080d11'); glow.addColorStop(1, '#121c1d')
|
||||
boot.fillStyle = glow; boot.fillRect(0, 0, 512, 1024)
|
||||
boot.strokeStyle = '#e6c775'; boot.lineWidth = 12
|
||||
boot.beginPath(); boot.moveTo(256, 288); boot.lineTo(326, 314); boot.lineTo(315, 395)
|
||||
boot.quadraticCurveTo(294, 433, 256, 451); boot.quadraticCurveTo(218, 433, 197, 395)
|
||||
boot.lineTo(186, 314); boot.closePath(); boot.stroke()
|
||||
boot.fillStyle = '#e8ede9'; boot.textAlign = 'center'
|
||||
boot.font = '500 57px sans-serif'; boot.fillText('GrapheneOS', 256, 590)
|
||||
boot.fillStyle = '#d4bc78'; boot.font = '30px sans-serif'; boot.fillText('Verified boot', 256, 644)
|
||||
boot.fillStyle = '#859699'; boot.font = '23px sans-serif'; boot.fillText('Bootloader locked', 256, 684)
|
||||
const bootTexture = new THREE.CanvasTexture(bootCanvas); bootTexture.colorSpace = THREE.SRGBColorSpace; textures.push(bootTexture)
|
||||
const bootMaterial = new THREE.MeshBasicMaterial({ map: bootTexture, toneMapped: false }); materials.push(bootMaterial)
|
||||
const bootScreen = new THREE.Mesh(geometry('pixel-boot-screen', () => new THREE.PlaneGeometry(w - .19, h - .23)), bootMaterial)
|
||||
bootScreen.position.z = d / 2 + .043; root.add(bootScreen)
|
||||
disk(.035, 0, h / 2 - .13, d / 2 + .05, dark)
|
||||
box(.045, .31, .07, w / 2 + .019, .57, 0, silver, .018)
|
||||
box(.045, .17, .07, w / 2 + .019, .15, 0, silver, .018)
|
||||
box(w * .82, .42, .07, 0, h * .29, -d / 2 - .04, black, .18)
|
||||
for (const x of [-.43, .03, .47]) {
|
||||
disk(.135, x, h * .29, -d / 2 - .085, silver)
|
||||
disk(.095, x, h * .29, -d / 2 - .098, dark)
|
||||
disk(.04, x, h * .29, -d / 2 - .11, glass)
|
||||
}
|
||||
box(.13, .045, .025, 0, -h / 2 - .014, 0, dark, .014)
|
||||
} else if (spec.kind === 'board') {
|
||||
box(w, h, d, 0, 0, 0, body, .06)
|
||||
// Plated through holes, copper traces, solder joints and populated reverse.
|
||||
|
||||
@@ -255,6 +255,11 @@ const router = createRouter({
|
||||
name: 'publish-website',
|
||||
component: () => import('@/views/publishing/PublishingSetup.vue'),
|
||||
},
|
||||
{
|
||||
path: 'setup/non-surveillance-phone',
|
||||
name: 'non-surveillance-phone',
|
||||
component: () => import('@/views/goals/NonSurveillancePhone.vue'),
|
||||
},
|
||||
{
|
||||
path: 'goals/:goalId',
|
||||
name: 'goal-detail',
|
||||
|
||||
@@ -163,13 +163,22 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<router-link
|
||||
to="/dashboard/server/firewall-tunnels"
|
||||
class="glass-button mt-4 w-full gap-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Firewalls & tunnels
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</router-link>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
|
||||
<router-link
|
||||
to="/dashboard/server/firewall-tunnels"
|
||||
class="glass-button w-full gap-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Firewalls & tunnels
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/dashboard/server/openwrt"
|
||||
class="glass-button w-full gap-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
OpenWrt Gateway
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FipsNetworkCard />
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import DeviceModel from '@/components/DeviceModel.vue'
|
||||
import HardwareMarketplace from '@/components/HardwareMarketplace.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const shopOpen = ref(false)
|
||||
const showcase = ref<HTMLElement>()
|
||||
const showcaseHeight = ref<string>()
|
||||
|
||||
function openShop() {
|
||||
const height = showcase.value?.getBoundingClientRect().height
|
||||
if (height) showcaseHeight.value = `${height}px`
|
||||
shopOpen.value = true
|
||||
}
|
||||
async function closeShop() {
|
||||
shopOpen.value = false
|
||||
await nextTick()
|
||||
showcaseHeight.value = undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-12">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<BackButton desktop-margin="mb-0" @click="router.push({ path: '/dashboard', query: { tab: 'setup' } })" />
|
||||
<h1 class="text-lg font-semibold text-white">Get a non surveillance phone</h1>
|
||||
</div>
|
||||
<p class="text-sm text-white/75 max-w-2xl">Your phone should answer to you. Choose a supported Pixel, install GrapheneOS, and keep verified boot on.</p>
|
||||
|
||||
<div ref="showcase" class="glass-card p-6 overflow-hidden phone-showcase" :style="showcaseHeight ? { height: showcaseHeight } : undefined">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)] gap-6 items-start phone-showcase__grid">
|
||||
<DeviceModel v-if="!shopOpen" model="pixel-graphene" label="Pixel phone with GrapheneOS verified boot" fill-viewport :controls="false" />
|
||||
<HardwareMarketplace kind="phone" :expanded="shopOpen" @open="openShop" @close="closeShop" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.phone-showcase { margin: 24px 0 48px; }
|
||||
.phone-showcase__grid { height: 100%; min-height: 0; grid-template-rows: minmax(0, 1fr); }
|
||||
@media (min-width: 1024px) {
|
||||
.phone-showcase__grid > :deep(.router-marketplace) { align-self: end; margin-top: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import DeviceModel from '@/components/DeviceModel.vue'
|
||||
import RouterMarketplace from '@/components/RouterMarketplace.vue'
|
||||
import HardwareMarketplace from '@/components/HardwareMarketplace.vue'
|
||||
|
||||
const router = useRouter()
|
||||
function goBack() {
|
||||
@@ -549,7 +549,7 @@ onMounted(() => {
|
||||
<div ref="routerCard" class="glass-card p-6 mb-6 overflow-hidden router-model-card" :style="routerCardHeight ? { height: routerCardHeight } : undefined">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)] gap-6 items-start">
|
||||
<DeviceModel v-if="!routerShopOpen" model="beryl-ax" label="Beryl AX (GL-MT3000) OpenWrt router" fill-viewport :controls="false" />
|
||||
<RouterMarketplace :expanded="routerShopOpen" @open="openRouterShop" @close="closeRouterShop" />
|
||||
<HardwareMarketplace :expanded="routerShopOpen" @open="openRouterShop" @close="closeRouterShop" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-16" aria-hidden="true"></div>
|
||||
@@ -1038,10 +1038,10 @@ onMounted(() => {
|
||||
.router-model-card {
|
||||
margin: 24px 0 48px;
|
||||
}
|
||||
.router-model-card > .grid { height: 100%; }
|
||||
.router-model-card > .grid { height: 100%; min-height: 0; grid-template-rows: minmax(0, 1fr); }
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.router-model-card > .grid > .router-marketplace {
|
||||
.router-model-card > .grid > :deep(.router-marketplace) {
|
||||
align-self: end;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user