Merge #a9165a5e: 3D hardware models, NIP-99 discovery, and private phon…
Demo images / Build & push demo images (push) Failing after 41s
Demo images / Build & push demo images (push) Failing after 41s
3D hardware models, NIP-99 discovery, and private phone goal nostr:nevent1qqs2j9j6tc3kpsdp3jhwty3wt8yy3un7r93e4d4smjpza4nvt033ctgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcq28amm PR-Author: Personal nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg PR description: Adds refined 3D hardware illustrations, OpenWrt and private-phone marketplace discovery, and the Setup goal for a non-surveillance phone. Production queries enabled Archipelago relays for NIP-99 kind-30402 listings; development mock cards are used only when DEV is enabled. Tested with production build plus Chromium, Firefox, and WebKit desktop/mobile browser checks. File-browser goal completion now opens the native Cloud files view.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { createDeviceScene } from '@/graphics/deviceScene'
|
||||
import { deviceSpecs } from '@/graphics/deviceModels'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
model: string
|
||||
label: string
|
||||
fallback?: string
|
||||
fillViewport?: boolean
|
||||
glassContainer?: boolean
|
||||
controls?: boolean
|
||||
/** Development-only gallery controls. Never rendered in production builds. */
|
||||
devBrowse?: boolean
|
||||
}>(), { fallback: '', fillViewport: false, glassContainer: false, controls: true, devBrowse: false })
|
||||
const host = ref<HTMLElement>()
|
||||
const frame = ref<HTMLElement>()
|
||||
const ready = ref(false)
|
||||
const failed = ref(false)
|
||||
const paused = ref(false)
|
||||
const reducedMotion = ref(false)
|
||||
const currentModel = ref(props.model)
|
||||
const devModelIds = Object.keys(deviceSpecs)
|
||||
const devBrowseEnabled = import.meta.env.DEV && props.devBrowse
|
||||
const dragging = ref(false)
|
||||
const remainingHeight = ref('360px')
|
||||
let view: ReturnType<typeof createDeviceScene> | undefined
|
||||
let observer: IntersectionObserver | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let motion: MediaQueryList | undefined
|
||||
let visible = false
|
||||
let mounted = false
|
||||
let generation = 0
|
||||
let raf = 0
|
||||
let previous = 0
|
||||
let lastDraw = 0
|
||||
let pointer: number | undefined
|
||||
let lastX = 0
|
||||
let lastY = 0
|
||||
|
||||
function devModelLabel(id: string) { return deviceSpecs[id]?.name ?? id }
|
||||
function browseModel(offset: number) {
|
||||
if (!devBrowseEnabled || !view) return
|
||||
const index = devModelIds.indexOf(currentModel.value)
|
||||
const next = devModelIds[(index + offset + devModelIds.length) % devModelIds.length]!
|
||||
currentModel.value = next
|
||||
view.setModel(next)
|
||||
view.render()
|
||||
}
|
||||
|
||||
function stop() { cancelAnimationFrame(raf); raf = 0; previous = 0; lastDraw = 0 }
|
||||
function canSpin() { return view && visible && !document.hidden && !paused.value && !motion?.matches && !dragging.value }
|
||||
function animate(now: number) {
|
||||
raf = 0
|
||||
if (!canSpin()) { previous = 0; return }
|
||||
// Cap to 30fps; complete a turn in about a minute, independent of refresh rate.
|
||||
if (now - lastDraw >= 32) {
|
||||
view!.rotation.y += previous ? Math.min((now - previous) / 1000, .1) * .105 : 0
|
||||
previous = now; lastDraw = now; view!.render()
|
||||
}
|
||||
raf = requestAnimationFrame(animate)
|
||||
}
|
||||
function sync() { reducedMotion.value = !!motion?.matches; stop(); if (canSpin()) raf = requestAnimationFrame(animate) }
|
||||
function layout() {
|
||||
if (props.fillViewport && frame.value) {
|
||||
// Use the layout position, not its changing scrolled screen position.
|
||||
let scroll = 0
|
||||
for (let el = frame.value.parentElement; el; el = el.parentElement) scroll += el.scrollTop
|
||||
const top = frame.value.getBoundingClientRect().top + scroll
|
||||
// Keep a visible page gutter below the showcase; the frame sits inside a
|
||||
// glass card whose bottom margin must remain visible in the viewport.
|
||||
remainingHeight.value = `${Math.max(260, window.innerHeight - top - 88)}px`
|
||||
}
|
||||
view?.resize()
|
||||
}
|
||||
function disposeScene() {
|
||||
stop(); ready.value = false
|
||||
view?.canvas.removeEventListener('webglcontextlost', contextLost)
|
||||
view?.dispose(); view = undefined
|
||||
}
|
||||
function contextLost(event: Event) {
|
||||
event.preventDefault(); failed.value = true; disposeScene()
|
||||
}
|
||||
async function init() {
|
||||
if (!mounted || !visible || view || failed.value) return
|
||||
const token = ++generation
|
||||
try {
|
||||
const { createDeviceScene } = await import('@/graphics/deviceScene')
|
||||
if (!mounted || token !== generation || !host.value) return
|
||||
view = createDeviceScene(host.value, props.model)
|
||||
view.canvas.addEventListener('webglcontextlost', contextLost)
|
||||
ready.value = true; layout(); sync()
|
||||
} catch {
|
||||
if (token === generation) { failed.value = true; disposeScene() }
|
||||
}
|
||||
}
|
||||
function down(event: PointerEvent) {
|
||||
if (!view || (event.pointerType === 'mouse' && event.button !== 0) || pointer !== undefined) return
|
||||
pointer = event.pointerId; lastX = event.clientX; lastY = event.clientY
|
||||
dragging.value = true; stop(); host.value?.setPointerCapture(pointer)
|
||||
}
|
||||
function move(event: PointerEvent) {
|
||||
if (pointer !== event.pointerId || !view) return
|
||||
view.rotation.y += (event.clientX - lastX) * .012
|
||||
// Touch preserves vertical page scrolling; mouse can also tilt the model.
|
||||
if (event.pointerType === 'mouse') view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.clientY - lastY) * .008))
|
||||
lastX = event.clientX; lastY = event.clientY; view.render()
|
||||
}
|
||||
function up() { pointer = undefined; dragging.value = false; sync() }
|
||||
function key(event: KeyboardEvent) {
|
||||
if (!view || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
|
||||
event.preventDefault(); paused.value = true; stop()
|
||||
if (event.key === 'Home') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, -.4, 0)
|
||||
else if (event.key === 'End') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, Math.PI - .4, 0)
|
||||
else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') view.rotation.y += event.key === 'ArrowLeft' ? -.2 : .2
|
||||
else view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.key === 'ArrowUp' ? -.12 : .12)))
|
||||
view.render()
|
||||
}
|
||||
watch(paused, sync)
|
||||
watch(() => props.model, () => {
|
||||
generation++; up()
|
||||
currentModel.value = props.model
|
||||
if (view) {
|
||||
try { view.setModel(props.model) } catch { failed.value = true; disposeScene() }
|
||||
} else { failed.value = false; void init() }
|
||||
})
|
||||
onMounted(() => {
|
||||
mounted = true
|
||||
if (!window.matchMedia || !window.IntersectionObserver || !window.ResizeObserver) {
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
motion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
motion.addEventListener('change', sync)
|
||||
document.addEventListener('visibilitychange', sync)
|
||||
window.addEventListener('resize', layout)
|
||||
resizeObserver = new ResizeObserver(layout)
|
||||
if (host.value) resizeObserver.observe(host.value)
|
||||
if (frame.value?.parentElement) resizeObserver.observe(frame.value.parentElement)
|
||||
observer = new IntersectionObserver(entries => {
|
||||
visible = entries.some(entry => entry.isIntersecting)
|
||||
if (visible) void init()
|
||||
sync()
|
||||
})
|
||||
if (frame.value) observer.observe(frame.value)
|
||||
layout()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false; generation++; observer?.disconnect(); resizeObserver?.disconnect()
|
||||
motion?.removeEventListener('change', sync)
|
||||
document.removeEventListener('visibilitychange', sync); window.removeEventListener('resize', layout)
|
||||
disposeScene()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure ref="frame" class="device-model" :class="{ 'device-model--viewport': fillViewport, 'device-model--glass': glassContainer }"
|
||||
:style="{ '--device-height': remainingHeight }" :data-model="model" :data-ready="ready">
|
||||
<div ref="host" class="device-model__stage" :class="{ 'is-dragging': dragging }"
|
||||
:tabindex="ready ? 0 : -1" role="group" :aria-label="`${label}. Drag or use arrow keys to rotate. Home shows the front; End shows the back.`"
|
||||
@pointerdown="down" @pointermove="move" @pointerup="up" @pointercancel="up" @lostpointercapture="up" @keydown="key">
|
||||
<img v-if="!ready && fallback" :src="fallback" :alt="label" class="device-model__fallback" />
|
||||
<div v-else-if="!ready" class="device-model__placeholder" role="status" aria-label="Loading 3D illustration">
|
||||
<span class="device-model__loader" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption v-if="controls || devBrowseEnabled" class="device-model__caption">
|
||||
<span>{{ failed ? label : (devBrowseEnabled ? devModelLabel(currentModel) : 'Drag to explore') }}</span>
|
||||
<template v-if="devBrowseEnabled">
|
||||
<button type="button" aria-label="Previous 3D model" @click="browseModel(-1)">Back</button>
|
||||
<span class="device-model__counter">{{ devModelIds.indexOf(currentModel) + 1 }} / {{ devModelIds.length }}</span>
|
||||
<button type="button" aria-label="Next 3D model" @click="browseModel(1)">Next</button>
|
||||
</template>
|
||||
<button v-if="ready && !reducedMotion" type="button" :aria-label="paused ? 'Resume automatic rotation' : 'Pause automatic rotation'"
|
||||
:aria-pressed="paused" @click="paused = !paused">{{ paused ? 'Rotate' : 'Pause' }}</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-model { position: relative; width: 100%; height: 240px; margin: 0; isolation: isolate; }
|
||||
.device-model--glass { padding: 16px 18px 12px; border: 1px solid rgba(255,255,255,.13); border-radius: 14px; background: linear-gradient(135deg, rgba(255,255,255,.075), rgba(255,255,255,.025)); box-shadow: inset 0 1px 0 rgba(255,255,255,.045), 0 18px 55px rgba(0,0,0,.14); }
|
||||
.device-model--glass::after { content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; background: linear-gradient(120deg, rgba(255,255,255,.1), transparent 34%, transparent 72%, rgba(255,255,255,.035)); opacity: .45; }
|
||||
.device-model::before { content: ''; position: absolute; inset: 14% 9%; background: radial-gradient(ellipse, rgba(110,167,180,.09), transparent 68%); pointer-events: none; z-index: -1; }
|
||||
.device-model__stage { position: absolute; inset: 0 0 28px; cursor: grab; touch-action: pan-y; border-radius: 16px; }
|
||||
.device-model__stage.is-dragging { cursor: grabbing; }
|
||||
.device-model__stage:focus-visible { outline: 1px solid rgba(255,255,255,.5); outline-offset: 2px; }
|
||||
.device-model__stage :deep(canvas) { display: block; width: 100%; height: 100%; }
|
||||
.device-model__fallback { width: 100%; height: 100%; object-fit: contain; padding: 22px; }
|
||||
.device-model__placeholder { height: 100%; display: grid; place-items: center; }
|
||||
.device-model__loader { display: flex; align-items: center; gap: 8px; opacity: .7; }
|
||||
.device-model__loader i { display: block; width: 7px; height: 7px; border-radius: 999px; background: rgba(140,228,213,.75); animation: device-model-pulse 1.1s ease-in-out infinite; }
|
||||
.device-model__loader i:nth-child(2) { animation-delay: .14s; }
|
||||
.device-model__loader i:nth-child(3) { animation-delay: .28s; }
|
||||
@keyframes device-model-pulse { 0%, 60%, 100% { transform: scale(.65); opacity: .35; } 30% { transform: scale(1); opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .device-model__loader i { animation: none; opacity: .65; } }
|
||||
.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: 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'] : ['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/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>
|
||||
@@ -8,25 +8,11 @@
|
||||
>
|
||||
<!-- Step 1: detection graphic + what's currently on the radio -->
|
||||
<div v-if="step === 1" class="text-center">
|
||||
<div class="mx-auto my-2 w-40 h-40 relative">
|
||||
<!-- pulsing signal waves behind the board image -->
|
||||
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true">
|
||||
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M130 44 a30 30 0 0 1 0 28" />
|
||||
<path d="M138 36 a42 42 0 0 1 0 44" />
|
||||
<path d="M146 28 a54 54 0 0 1 0 60" />
|
||||
</g>
|
||||
</svg>
|
||||
<!-- actual board image (vendored from the Meshtastic web flasher) -->
|
||||
<img
|
||||
:src="deviceImage.image"
|
||||
:alt="deviceImage.label"
|
||||
class="relative w-full h-full object-contain drop-shadow-[0_8px_24px_rgba(251,146,60,0.25)]"
|
||||
@error="imageFailed = true"
|
||||
v-if="!imageFailed"
|
||||
/>
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center text-5xl">📡</div>
|
||||
</div>
|
||||
<DeviceModel
|
||||
:model="deviceImage.model"
|
||||
:label="deviceImage.label"
|
||||
dev-browse
|
||||
/>
|
||||
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
|
||||
<p class="text-white/60 text-xs mt-1">
|
||||
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
|
||||
@@ -306,6 +292,7 @@ import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type Flas
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor, RNODE_REGION_PLANS } from '@/utils/loraRegions'
|
||||
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
|
||||
import DeviceModel from '@/components/DeviceModel.vue'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
const appStore = useAppStore()
|
||||
@@ -344,7 +331,6 @@ function stopProbeProgress(done = false) {
|
||||
|
||||
const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
|
||||
const show = computed(() => !!devicePath.value)
|
||||
const imageFailed = ref(false)
|
||||
const deviceImage = computed(() =>
|
||||
resolveMeshDeviceImage(
|
||||
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
|
||||
@@ -417,7 +403,6 @@ watch([show, devicePath], async ([visible]) => {
|
||||
probeError.value = ''
|
||||
probing.value = false
|
||||
stopProbeProgress()
|
||||
imageFailed.value = false
|
||||
openFlashStep()
|
||||
const t = (mesh.status?.device_type ?? '').toLowerCase()
|
||||
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
|
||||
@@ -427,7 +412,6 @@ watch([show, devicePath], async ([visible]) => {
|
||||
}
|
||||
step.value = 1
|
||||
error.value = ''
|
||||
imageFailed.value = false
|
||||
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
|
||||
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
|
||||
form.value.channel = mesh.status?.channel_name || 'archipelago'
|
||||
@@ -629,17 +613,3 @@ function closeFlashStep() {
|
||||
step.value = 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mesh-detect-waves path {
|
||||
animation: mesh-wave-pulse 2.2s ease-out infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
.mesh-detect-waves path:nth-child(2) { animation-delay: 0.35s; }
|
||||
.mesh-detect-waves path:nth-child(3) { animation-delay: 0.7s; }
|
||||
@keyframes mesh-wave-pulse {
|
||||
0% { opacity: 0; }
|
||||
25% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user