feat(ui): add private phone setup goal and shared hardware listings

This commit is contained in:
yaya
2026-10-09 14:31:24 +01:00
parent 03ee3c0162
commit cfde1343a4
16 changed files with 352 additions and 227 deletions
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

+1 -1
View File
@@ -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>
+1
View File
@@ -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>
+7
View File
@@ -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',
+37 -1
View File
@@ -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.
+5
View File
@@ -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',
+16 -7
View File
@@ -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 &amp; 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 &amp; 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>
+4 -4
View File
@@ -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;
}