feat(ui): mobile mesh tabs, AIUI-style audio player, cloud grid + map fixes
UI (this session): - Global audio player now scales the whole interface into the space above it on desktop (sidebar + main) and docks directly above the tab bar on mobile; it stays visible while navigating. - Mesh mobile redesign: floating Chat / BTC / Dead Man / AI / Map tab strip with a single fixed, internally-scrolling pane (page no longer scrolls); tabs hide while a conversation is open; floating back button; collapsible Device panel (starts collapsed); keyboard-aware conversation sizing via VisualViewport so the chat sits just above the keyboard. - Cloud file grid: uniform 4/3 card heights (folders + images match). - Swipe left/right switches tabs on the Apps and Web5 screens. - Map tool fills its pane (no bottom gap); fix skewed Share Location toggle on mobile (global min-height rule was deforming the switch). - Trim redundant helper copy from the mesh AI tab. Also bundles pre-existing in-progress work that was already in the tree: mesh listener/session + wallet + container + bitcoin-status backend changes, docker UI updates, and assorted other UI tweaks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c4855526fe
commit
1bce694ebb
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<!-- Spacer to prevent content from being hidden behind the player -->
|
||||
<div v-if="audioPlayer.currentName.value" class="h-14"></div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="audioPlayer.currentName.value"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 audio-player-bar"
|
||||
ref="barEl"
|
||||
class="fixed left-0 right-0 z-40 audio-player-bar"
|
||||
>
|
||||
<!-- Progress bar (clickable) -->
|
||||
<div
|
||||
@@ -60,9 +58,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const barEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Publish the player's height as a CSS variable so page scroll containers can
|
||||
// reserve space for it (the same mechanism the mobile tab bar uses). This is
|
||||
// what pushes the rest of the site up instead of letting the fixed bar overlap
|
||||
// and block the bottom controls — on desktop AND mobile, on every page.
|
||||
function setPlayerHeightVar() {
|
||||
if (typeof document === 'undefined') return
|
||||
const h = barEl.value?.offsetHeight || 60
|
||||
document.documentElement.style.setProperty('--audio-player-height', `${h}px`)
|
||||
document.documentElement.classList.add('audio-active')
|
||||
}
|
||||
|
||||
function clearPlayerHeightVar() {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.style.setProperty('--audio-player-height', '0px')
|
||||
document.documentElement.classList.remove('audio-active')
|
||||
}
|
||||
|
||||
watch(() => audioPlayer.currentName.value, (name) => {
|
||||
if (name) {
|
||||
nextTick(setPlayerHeightVar)
|
||||
} else {
|
||||
clearPlayerHeightVar()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(clearPlayerHeightVar)
|
||||
|
||||
function togglePlay() {
|
||||
if (audioPlayer.playing.value) {
|
||||
@@ -90,6 +117,10 @@ function formatTime(seconds: number): string {
|
||||
|
||||
<style scoped>
|
||||
.audio-player-bar {
|
||||
/* Sit directly above the mobile tab bar (its height is published as
|
||||
--mobile-tab-bar-height). On desktop the tab bar is hidden so the variable
|
||||
resolves to 0px and the bar docks flush to the bottom of the viewport. */
|
||||
bottom: var(--mobile-tab-bar-height, 0px);
|
||||
background: rgba(15, 15, 15, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
|
||||
@@ -471,6 +471,9 @@ onUnmounted(() => {
|
||||
.mesh-map-toggle {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
/* The global mobile rule forces buttons to min-height:44px, which stretches
|
||||
this switch and pushes the knob off-centre. Pin it back to the pill size. */
|
||||
min-height: 20px !important;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div v-if="receiveMethod === 'ecash'">
|
||||
<div class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
|
||||
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass font-mono"></textarea>
|
||||
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
|
||||
</div>
|
||||
@@ -119,7 +119,7 @@ async function receive() {
|
||||
if (receiveMethod.value === 'lightning') {
|
||||
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
|
||||
const res = await rpcClient.call<{ payment_request: string }>({
|
||||
method: 'lnd.addinvoice',
|
||||
method: 'lnd.createinvoice',
|
||||
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
|
||||
})
|
||||
invoiceResult.value = res.payment_request
|
||||
@@ -133,11 +133,16 @@ async function receive() {
|
||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||
} else {
|
||||
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
|
||||
await rpcClient.call<{ amount_sats: number }>({
|
||||
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
|
||||
// redeemed at its mint, anything else is reissued as Fedimint notes.
|
||||
const res = await rpcClient.call<{ received_sats?: number; kind?: string }>({
|
||||
method: 'wallet.ecash-receive',
|
||||
params: { token: ecashToken.value.trim() },
|
||||
})
|
||||
ecashResult.value = t('receiveBitcoin.tokenReceivedSuccess')
|
||||
const kind = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
|
||||
ecashResult.value = res.received_sats != null
|
||||
? `Received ${res.received_sats.toLocaleString()} sats (${kind})!`
|
||||
: t('receiveBitcoin.tokenReceivedSuccess')
|
||||
emit('received')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -152,11 +152,10 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
|
||||
const { playing: audioPlaying, currentSrc } = useAudioPlayer()
|
||||
const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value)
|
||||
|
||||
const aspectClass = computed(() => {
|
||||
if (isImage.value || isVideo.value) return 'aspect-square'
|
||||
if (category.value === 'document' || category.value === 'folder') return 'aspect-[4/3]'
|
||||
return 'aspect-square'
|
||||
})
|
||||
// Uniform card cover ratio across every file type so folders, images, videos
|
||||
// and documents all render at the same height in the grid (previously images/
|
||||
// videos were square while folders were 4/3, giving a ragged, mismatched grid).
|
||||
const aspectClass = computed(() => 'aspect-[4/3]')
|
||||
|
||||
const coverBg = computed(() => {
|
||||
if (props.item.isDir) return 'bg-amber-500/10'
|
||||
|
||||
@@ -613,9 +613,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
// ─── Scroll Support ────────────────────────────────────────
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active) return
|
||||
let p = active.parentElement
|
||||
// Scroll the container UNDER THE POINTER, not the focused element. Real
|
||||
// wheel events always target the element beneath the cursor, so walking up
|
||||
// from e.target matches native behaviour. Using document.activeElement here
|
||||
// caused the wheel to scroll a previously-clicked container (e.g. the mesh
|
||||
// peer list, still focused after a click) instead of the panel actually
|
||||
// being hovered — producing a double-scroll where both moved at once.
|
||||
const start = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null)
|
||||
if (!start) return
|
||||
let p: HTMLElement | null = start
|
||||
while (p) {
|
||||
const style = getComputedStyle(p)
|
||||
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
|
||||
|
||||
@@ -127,6 +127,13 @@ export interface BlockHeader {
|
||||
announced_by: string
|
||||
}
|
||||
|
||||
export interface DeniedAsker {
|
||||
contact_id: number
|
||||
name: string
|
||||
pubkey_hex: string | null
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface AssistantStatus {
|
||||
enabled: boolean
|
||||
model: string | null
|
||||
@@ -137,6 +144,7 @@ export interface AssistantStatus {
|
||||
ollama_detected: boolean
|
||||
claude_available: boolean
|
||||
models: string[]
|
||||
denied_askers?: DeniedAsker[]
|
||||
}
|
||||
|
||||
export interface ScheduledMessage {
|
||||
|
||||
+38
-10
@@ -130,19 +130,45 @@ select:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll container bottom padding — desktop breathing room */
|
||||
/* Height of the global audio player bar — 0 unless it is visible. Set on
|
||||
<html> by GlobalAudioPlayer.vue. Scroll containers add it to their bottom
|
||||
padding so the fixed player pushes content up instead of covering it. */
|
||||
:root {
|
||||
--audio-player-height: 0px;
|
||||
}
|
||||
|
||||
/* Scroll container bottom padding — desktop breathing room. (On desktop the
|
||||
audio player instead shrinks the whole #main-content area — see the
|
||||
html.audio-active rule below — so no player offset is added here.) */
|
||||
.mobile-scroll-pad,
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
/* Audio player docked: shrink the whole interface into the space above it so
|
||||
the entire view scales up (like the AIUI iframe) instead of just gaining
|
||||
scroll padding. On desktop the player spans full width and BOTH the sidebar
|
||||
and the main content scale into the reduced height above it. Mobile keeps the
|
||||
tab-bar + player handled via .mobile-scroll-pad padding. */
|
||||
@media (min-width: 768px) {
|
||||
html.audio-active .dashboard-view {
|
||||
height: calc(100dvh - var(--audio-player-height, 0px));
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Sidebar uses h-screen (100vh) — pin it to the reduced container height. */
|
||||
html.audio-active .dashboard-view [data-controller-zone="sidebar"] {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: override with tab bar clearance */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-scroll-pad {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
|
||||
}
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
|
||||
}
|
||||
|
||||
/* Safe area top padding for all mobile content views.
|
||||
@@ -174,11 +200,11 @@ select:focus-visible {
|
||||
}
|
||||
|
||||
.mobile-scroll-pad {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
|
||||
}
|
||||
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
|
||||
}
|
||||
|
||||
.mobile-safe-top {
|
||||
@@ -525,6 +551,7 @@ input[type="radio"]:active + * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
/* On mobile browsers, cap chat height to the dynamic viewport to prevent
|
||||
content extending behind browser chrome (address bar / toolbar). */
|
||||
@media (max-width: 767px) {
|
||||
@@ -555,8 +582,8 @@ input[type="radio"]:active + * {
|
||||
context) stay above the tab bar instead of sliding underneath it. */
|
||||
@media (max-width: 767px) {
|
||||
.chat-iframe-mobile {
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
@@ -1926,18 +1953,19 @@ html.modal-scroll-locked .dashboard-scroll-panel {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ── Mobile floating back/close button (always 8px above tab bar) ──── */
|
||||
/* ── Mobile floating back/close button (always 8px above tab bar — and above
|
||||
the audio player too when it is showing, so it never hides behind it) ── */
|
||||
.mobile-back-btn {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + 8px);
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
|
||||
z-index: 40;
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.mobile-filter-btn {
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
|
||||
@@ -95,9 +95,11 @@
|
||||
/>
|
||||
<!-- Re-key on the current folder path so the depth/zoom animation replays
|
||||
at every level (folder → subfolder → …), not just on first entry.
|
||||
Only the file content zooms; the header + breadcrumb nav above stay
|
||||
fixed in place. -->
|
||||
<Transition name="cloud-zoom" mode="out-in">
|
||||
The transition name flips with navigation direction so descending
|
||||
zooms forward and going back up zooms in reverse — matching the
|
||||
cloud → folder route transition. Only the file content zooms; the
|
||||
header + breadcrumb nav above stay fixed in place. -->
|
||||
<Transition :name="folderTransition" mode="out-in">
|
||||
<FileGrid
|
||||
:key="cloudStore.currentPath"
|
||||
:items="cloudStore.sortedItems"
|
||||
@@ -177,6 +179,19 @@ const cloudStore = useCloudStore()
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
// Direction-aware folder zoom: descending into a subfolder plays the same
|
||||
// "depth-forward" feel as the cloud → folder route transition (new arrives from
|
||||
// depth, current zooms out toward the viewer); navigating back up plays its
|
||||
// mirror ("depth-back"). Picked by comparing folder depth on each path change.
|
||||
const folderTransition = ref<'cloud-zoom-forward' | 'cloud-zoom-back'>('cloud-zoom-forward')
|
||||
let prevFolderDepth = -1
|
||||
watch(() => cloudStore.currentPath, (path) => {
|
||||
const depth = path.split('/').filter(Boolean).length
|
||||
// First render (prevFolderDepth === -1) defaults to forward.
|
||||
folderTransition.value = depth < prevFolderDepth ? 'cloud-zoom-back' : 'cloud-zoom-forward'
|
||||
prevFolderDepth = depth
|
||||
})
|
||||
|
||||
const iframeLoaded = ref(false)
|
||||
const uploading = ref(false)
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
@@ -395,37 +410,60 @@ function goBack() {
|
||||
</script>
|
||||
|
||||
<!-- Not scoped: the transition classes are applied to the FileGrid child's root
|
||||
element, which lives outside this component's style scope. Matches the
|
||||
`depth-forward` route transition feel (zoom in from depth + blur). -->
|
||||
element, which lives outside this component's style scope. Mirrors the
|
||||
`depth-forward` / `depth-back` route transitions (same scale magnitudes +
|
||||
blur) so descending into a folder and going back up feel identical to the
|
||||
cloud ⇄ folder route change. -->
|
||||
<style>
|
||||
.cloud-zoom-enter-active,
|
||||
.cloud-zoom-leave-active {
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition:
|
||||
opacity 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform-origin: center center;
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
/* New folder zooms in from depth */
|
||||
.cloud-zoom-enter-from {
|
||||
|
||||
/* Forward (into a deeper folder): new folder arrives from depth while the
|
||||
current one zooms out toward the viewer — matches depth-forward. */
|
||||
.cloud-zoom-forward-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.82);
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
/* Previous folder recedes forward as it leaves */
|
||||
.cloud-zoom-leave-to {
|
||||
.cloud-zoom-forward-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.12);
|
||||
filter: blur(6px);
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Back (up to a parent folder): the mirror — new folder shrinks in from the
|
||||
front while the current one recedes into depth — matches depth-back. */
|
||||
.cloud-zoom-back-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.cloud-zoom-back-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cloud-zoom-enter-active,
|
||||
.cloud-zoom-leave-active {
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.cloud-zoom-enter-from,
|
||||
.cloud-zoom-leave-to {
|
||||
.cloud-zoom-forward-enter-from,
|
||||
.cloud-zoom-forward-leave-to,
|
||||
.cloud-zoom-back-enter-from,
|
||||
.cloud-zoom-back-leave-to {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
tabindex="-1"
|
||||
@pointerenter="activateMainScroll"
|
||||
@wheel.capture="activateMainScroll"
|
||||
@touchstart.passive="onContentTouchStart"
|
||||
@touchend.passive="onContentTouchEnd"
|
||||
>
|
||||
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||
<!-- Controller zone entry point - no switcher -->
|
||||
@@ -253,6 +255,72 @@ function activateMainScroll() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Swipe left/right to move between the mobile top tabs ──────────────────
|
||||
// Apps screen: My Apps ⇄ App Store ⇄ Services
|
||||
// Web5 screen: Web5 ⇄ Cloud ⇄ Network ⇄ Mesh
|
||||
// Only active while the matching tab strip is actually showing (mobile).
|
||||
type TabTarget = { key: string; to: { path: string; query: Record<string, string> } }
|
||||
const APPS_TABS: TabTarget[] = [
|
||||
{ key: 'myapps', to: { path: '/dashboard/apps', query: {} } },
|
||||
{ key: 'store', to: { path: '/dashboard/discover', query: {} } },
|
||||
{ key: 'services', to: { path: '/dashboard/apps', query: { tab: 'services' } } },
|
||||
]
|
||||
const NET_TABS: TabTarget[] = [
|
||||
{ key: 'web5', to: { path: '/dashboard/web5', query: {} } },
|
||||
{ key: 'cloud', to: { path: '/dashboard/cloud', query: {} } },
|
||||
{ key: 'server', to: { path: '/dashboard/server', query: {} } },
|
||||
{ key: 'mesh', to: { path: '/dashboard/mesh', query: {} } },
|
||||
]
|
||||
|
||||
function activeAppsKey(): string {
|
||||
if (route.query.tab === 'services' || route.query.tab === 'websites') return 'services'
|
||||
if (route.path.includes('/marketplace') || route.path.includes('/discover')) return 'store'
|
||||
if (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps')) return 'myapps'
|
||||
return ''
|
||||
}
|
||||
function activeNetKey(): string {
|
||||
const p = route.path
|
||||
if (p.startsWith('/dashboard/web5')) return 'web5'
|
||||
if (p.startsWith('/dashboard/cloud')) return 'cloud'
|
||||
if (p.startsWith('/dashboard/server')) return 'server'
|
||||
if (p.startsWith('/dashboard/mesh')) return 'mesh'
|
||||
return ''
|
||||
}
|
||||
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
let touchStartTime = 0
|
||||
function onContentTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
if (!t) return
|
||||
touchStartX = t.clientX
|
||||
touchStartY = t.clientY
|
||||
touchStartTime = e.timeStamp
|
||||
}
|
||||
function onContentTouchEnd(e: TouchEvent) {
|
||||
const t = e.changedTouches[0]
|
||||
if (!t) return
|
||||
const dx = t.clientX - touchStartX
|
||||
const dy = t.clientY - touchStartY
|
||||
const dt = e.timeStamp - touchStartTime
|
||||
// Clear horizontal flick: far enough, mostly sideways, and quick.
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.8 || dt > 600) return
|
||||
|
||||
const nav = mobileNavRef.value
|
||||
if (!nav) return
|
||||
let tabs: TabTarget[] | null = null
|
||||
let activeKey = ''
|
||||
if (nav.showAppsTabs) { tabs = APPS_TABS; activeKey = activeAppsKey() }
|
||||
else if (nav.showNetworkTabs) { tabs = NET_TABS; activeKey = activeNetKey() }
|
||||
if (!tabs) return
|
||||
|
||||
const idx = tabs.findIndex(tb => tb.key === activeKey)
|
||||
if (idx < 0) return
|
||||
const next = idx + (dx < 0 ? 1 : -1) // swipe left → next tab, right → previous
|
||||
if (next < 0 || next >= tabs.length) return
|
||||
router.push(tabs[next].to).catch(() => {})
|
||||
}
|
||||
|
||||
watch(() => route.path, (newPath) => {
|
||||
const isAppDetails = isDetailRoute(newPath)
|
||||
const wasAppDetails = showAltBackground.value
|
||||
|
||||
+83
-27
@@ -38,6 +38,8 @@ const configuring = ref(false)
|
||||
const connectingDevice = ref<string | null>(null)
|
||||
const chatScrollEl = ref<HTMLElement | null>(null)
|
||||
const mobileShowChat = ref(false)
|
||||
// Device status panel starts collapsed on mobile (expandable via its header).
|
||||
const deviceExpanded = ref(false)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
let wsUnsub: (() => void) | null = null
|
||||
|
||||
@@ -261,6 +263,15 @@ const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('cha
|
||||
// Tools tab for 3rd column on wide desktop and mobile below-chat
|
||||
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map'>('bitcoin')
|
||||
|
||||
// Mobile: a single set of floating tabs drives the whole pane (Chat + tools).
|
||||
// 'chat' shows the peers list / active conversation; the rest swap the pane to
|
||||
// that tool. Selecting a tool leaves any open conversation.
|
||||
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
|
||||
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map') {
|
||||
mobileTab.value = tab
|
||||
if (tab !== 'chat') mobileShowChat.value = false
|
||||
}
|
||||
|
||||
// Panel visibility computeds
|
||||
const showChatPanel = computed(() =>
|
||||
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||
@@ -268,28 +279,29 @@ const showChatPanel = computed(() =>
|
||||
const showBitcoinPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'bitcoin'
|
||||
if (isMobile.value) return mobileTab.value === 'bitcoin'
|
||||
return activeTab.value === 'bitcoin'
|
||||
})
|
||||
const showDeadmanPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'deadman'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'deadman'
|
||||
if (isMobile.value) return mobileTab.value === 'deadman'
|
||||
return activeTab.value === 'deadman'
|
||||
})
|
||||
const showAssistantPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'assistant'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'assistant'
|
||||
if (isMobile.value) return mobileTab.value === 'assistant'
|
||||
return activeTab.value === 'assistant'
|
||||
})
|
||||
const showMapPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'map'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'map'
|
||||
if (isMobile.value) return mobileTab.value === 'map'
|
||||
return activeTab.value === 'map'
|
||||
})
|
||||
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
|
||||
// Mobile: tool pane shows whenever a non-chat tab is active.
|
||||
const showMobileTools = computed(() => isMobile.value && mobileTab.value !== 'chat')
|
||||
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
|
||||
|
||||
// Fetch session status when active peer changes
|
||||
@@ -313,10 +325,25 @@ async function handleToggleOffGrid() {
|
||||
} finally { togglingOffGrid.value = false }
|
||||
}
|
||||
|
||||
// Track the on-screen keyboard height (mobile) so the conversation pane + back
|
||||
// button can sit just above it — fixed elements ignore the keyboard otherwise
|
||||
// and the input ends up hidden behind it. Publishes --keyboard-inset on <html>.
|
||||
function updateKeyboardInset() {
|
||||
const vv = window.visualViewport
|
||||
if (!vv) return
|
||||
const inset = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
|
||||
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('click', handleDocClickForMenu)
|
||||
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', updateKeyboardInset)
|
||||
window.visualViewport.addEventListener('scroll', updateKeyboardInset)
|
||||
updateKeyboardInset()
|
||||
}
|
||||
loadPendingFromSession()
|
||||
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
|
||||
refreshOutboxCount()
|
||||
@@ -355,6 +382,11 @@ onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
document.removeEventListener('click', handleDocClickForMenu)
|
||||
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
|
||||
window.visualViewport.removeEventListener('scroll', updateKeyboardInset)
|
||||
}
|
||||
document.documentElement.style.removeProperty('--keyboard-inset')
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
|
||||
if (wsUnsub) { wsUnsub(); wsUnsub = null }
|
||||
@@ -885,10 +917,11 @@ function scrollChatToBottom() {
|
||||
}
|
||||
|
||||
// Wheel over the chat must scroll ONLY the chat — never leak to the contacts
|
||||
// list or the page. CSS overscroll-behavior wasn't enough (the leak happens
|
||||
// even when the chat doesn't overflow), so consume the wheel and apply it to
|
||||
// the chat container directly. Used with `@wheel.prevent` so the default
|
||||
// (page/contacts) scroll never fires.
|
||||
// list or the page. Bound with `@wheel.stop.prevent`: `.stop` keeps the event
|
||||
// from reaching the global controller-nav wheel handler (which would otherwise
|
||||
// also scroll whatever container is focused, e.g. the peer list after a click),
|
||||
// and `.prevent` stops the native page scroll. We then apply the delta to the
|
||||
// chat container directly.
|
||||
function onChatWheel(e: WheelEvent) {
|
||||
const el = chatScrollEl.value
|
||||
if (!el) return
|
||||
@@ -1354,12 +1387,15 @@ function isImageMime(mime?: string): boolean {
|
||||
<!-- Responsive column layout -->
|
||||
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }">
|
||||
<!-- LEFT COLUMN: Status + Peers -->
|
||||
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
||||
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat || mobileTab !== 'chat' }">
|
||||
<!-- Device Status -->
|
||||
<div data-controller-container tabindex="0" class="glass-card mesh-status-card">
|
||||
<div class="mesh-status-header">
|
||||
<div data-controller-container tabindex="0" class="glass-card mesh-status-card" :class="{ 'mesh-status-collapsed': !deviceExpanded }">
|
||||
<div class="mesh-status-header" role="button" tabindex="0" @click="deviceExpanded = !deviceExpanded" @keydown.enter.prevent="deviceExpanded = !deviceExpanded" @keydown.space.prevent="deviceExpanded = !deviceExpanded">
|
||||
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
|
||||
<h2 class="mesh-section-title">Device</h2>
|
||||
<svg class="mesh-status-chevron" :aria-expanded="deviceExpanded" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-if="mesh.loading && !mesh.status" class="mesh-loading">Loading...</div>
|
||||
@@ -1543,7 +1579,7 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Tabbed panels -->
|
||||
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat }">
|
||||
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat || mobileTab !== 'chat' }">
|
||||
<!-- Tab bar (medium desktop only) -->
|
||||
<div v-if="showTabBar" class="mesh-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
|
||||
@@ -1561,15 +1597,29 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card">
|
||||
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card" :class="{ 'mesh-chat-card-active': hasActiveChat }">
|
||||
<div v-if="!hasActiveChat" class="mesh-chat-empty">
|
||||
<div class="mesh-chat-empty-icon">📡</div>
|
||||
<p>Select a peer or channel to chat</p>
|
||||
<p class="mesh-chat-empty-sub">Messages are sent over LoRa mesh radio</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Mobile: floating back button (shared glass pill style), pinned
|
||||
above the tab bar — replaces the in-header arrow so the back
|
||||
control is no longer crammed inside the chat container. -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
type="button"
|
||||
class="mesh-chat-mobile-back mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium items-center justify-center gap-2"
|
||||
@click="closeChat"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<div class="mesh-chat-header">
|
||||
<button class="mesh-chat-back" @click="closeChat">←</button>
|
||||
<div class="mesh-chat-header-info">
|
||||
<div class="mesh-chat-header-name">
|
||||
<template v-if="renamingActive">
|
||||
@@ -1604,7 +1654,7 @@ function isImageMime(mime?: string): boolean {
|
||||
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.prevent="onChatWheel">
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
|
||||
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
|
||||
No messages yet. Say hello!
|
||||
</div>
|
||||
@@ -1851,17 +1901,6 @@ function isImageMime(mime?: string): boolean {
|
||||
|
||||
<!-- Mobile tools: show under peers list on first view -->
|
||||
<div v-if="showMobileTools" class="mesh-mobile-tools">
|
||||
<div class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'assistant' }" @click="toolsTab = 'assistant'">
|
||||
AI
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
|
||||
</div>
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||
@@ -1869,6 +1908,23 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: floating tab strip pinned above the global tab bar (same
|
||||
placement as the mobile back button). Switches the whole pane between
|
||||
the chat and each tool. Hidden while an individual conversation is open
|
||||
(the back button takes over there). -->
|
||||
<Teleport to="body">
|
||||
<div v-show="!mobileShowChat" class="mesh-mobile-tabbar">
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'chat' }" @click="selectMobileTab('chat')">Chat</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'bitcoin' }" @click="selectMobileTab('bitcoin')">BTC</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'deadman' }" @click="selectMobileTab('deadman')">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'assistant' }" @click="selectMobileTab('assistant')">AI</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'map' }" @click="selectMobileTab('map')">Map</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Transport chooser modal: shown when attachment size fits both mesh
|
||||
(inline-chunked) and Tor. User picks which path to send it over. -->
|
||||
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
|
||||
|
||||
@@ -316,14 +316,14 @@
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="lnPaying || onchainPaying"
|
||||
@click="payWithInvoice"
|
||||
@click="openQrPay"
|
||||
>
|
||||
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">Pay from another wallet (QR)</span>
|
||||
<span class="block text-sm text-white/50">Scan a Lightning invoice with any wallet</span>
|
||||
<span class="block text-sm text-white/50">Scan an on-chain or Lightning QR with any wallet</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -344,40 +344,84 @@
|
||||
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Lightning invoice -->
|
||||
<div v-else class="text-center">
|
||||
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/70">Requesting invoice from seller…</span>
|
||||
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
|
||||
<div v-else>
|
||||
<!-- Method tabs, styled like the wallet Send/Receive modal -->
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="m in (['onchain', 'lightning'] as const)"
|
||||
:key="m"
|
||||
@click="selectQrTab(m)"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="qrTab === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ m === 'onchain' ? 'On-chain' : 'Lightning' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="invoiceData">
|
||||
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
|
||||
</div>
|
||||
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
|
||||
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
|
||||
<!-- On-chain QR -->
|
||||
<div v-if="qrTab === 'onchain'" class="text-center">
|
||||
<div v-if="onchainWaiting && !onchainData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
Waiting for payment…
|
||||
</p>
|
||||
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
|
||||
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
|
||||
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
<span class="text-sm text-white/70">Requesting an address from the seller…</span>
|
||||
</div>
|
||||
<div v-else-if="onchainData">
|
||||
<div v-if="onchainQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="onchainQr" alt="On-chain payment QR" class="w-48 h-48" />
|
||||
</div>
|
||||
<p class="text-sm text-white mb-1">{{ onchainData.amount_sats }} sats</p>
|
||||
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 animate-spin text-orange-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
Waiting for payment…
|
||||
</p>
|
||||
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
|
||||
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ onchainData.address }}</code>
|
||||
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyOnchain">
|
||||
{{ onchainCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-white/40 mt-2">Needs 1 confirmation before the file unlocks.</p>
|
||||
</div>
|
||||
<p v-if="onchainError" class="text-sm text-red-400 mt-3">{{ onchainError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Lightning invoice QR -->
|
||||
<div v-else class="text-center">
|
||||
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/70">Requesting invoice from seller…</span>
|
||||
</div>
|
||||
<div v-else-if="invoiceData">
|
||||
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
|
||||
</div>
|
||||
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
|
||||
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
Waiting for payment…
|
||||
</p>
|
||||
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
|
||||
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
|
||||
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
|
||||
<button
|
||||
v-if="invoiceError"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm mt-3"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm mt-4 w-full"
|
||||
@click="payMode = 'choose'"
|
||||
>
|
||||
Back
|
||||
@@ -453,12 +497,21 @@ const audioPlayer = useAudioPlayer()
|
||||
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
||||
// they can pay from any external wallet by scanning a QR.
|
||||
const payItem = ref<CatalogItem | null>(null)
|
||||
const payMode = ref<'choose' | 'invoice'>('choose')
|
||||
const payMode = ref<'choose' | 'qr'>('choose')
|
||||
// Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal,
|
||||
// on-chain first (the default).
|
||||
const qrTab = ref<'onchain' | 'lightning'>('onchain')
|
||||
const invoiceData = ref<{ bolt11: string; payment_hash: string; price_sats: number } | null>(null)
|
||||
const invoiceQr = ref('')
|
||||
const invoiceWaiting = ref(false)
|
||||
const invoiceError = ref('')
|
||||
const invoiceCopied = ref(false)
|
||||
// On-chain QR (pay the seller's address from any external wallet).
|
||||
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
|
||||
const onchainQr = ref('')
|
||||
const onchainWaiting = ref(false)
|
||||
const onchainError = ref('')
|
||||
const onchainCopied = ref(false)
|
||||
const lnPaying = ref(false)
|
||||
const lnError = ref('')
|
||||
const onchainPaying = ref(false)
|
||||
@@ -660,11 +713,17 @@ async function downloadFile(item: CatalogItem) {
|
||||
function openPayModal(item: CatalogItem) {
|
||||
payItem.value = item
|
||||
payMode.value = 'choose'
|
||||
qrTab.value = 'onchain'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
invoiceWaiting.value = false
|
||||
invoiceError.value = ''
|
||||
invoiceCopied.value = false
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainWaiting.value = false
|
||||
onchainError.value = ''
|
||||
onchainCopied.value = false
|
||||
lnPaying.value = false
|
||||
lnError.value = ''
|
||||
onchainPaying.value = false
|
||||
@@ -675,9 +734,102 @@ function closePayModal() {
|
||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||
payItem.value = null
|
||||
invoiceWaiting.value = false
|
||||
onchainWaiting.value = false
|
||||
onchainPaying.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the "pay from another wallet" view: tabbed QR like the wallet's
|
||||
* Send/Receive modal, defaulting to the on-chain tab (so a QR is shown
|
||||
* immediately for any external wallet).
|
||||
*/
|
||||
function openQrPay() {
|
||||
payMode.value = 'qr'
|
||||
qrTab.value = 'onchain'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = false
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainError.value = ''
|
||||
loadOnchainQr()
|
||||
}
|
||||
|
||||
/** Switch QR tab, lazily loading that method's QR the first time it's shown and
|
||||
* resuming its payment poll if it was already loaded (so switching back and
|
||||
* forth doesn't silently stop watching for payment). */
|
||||
function selectQrTab(tab: 'onchain' | 'lightning') {
|
||||
if (qrTab.value === tab) return
|
||||
qrTab.value = tab
|
||||
if (tab === 'onchain') {
|
||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||
if (!onchainData.value && !onchainWaiting.value) {
|
||||
loadOnchainQr()
|
||||
} else if (onchainData.value && !onchainPaying.value) {
|
||||
onchainPaying.value = true
|
||||
pollOnchain(onchainData.value.address)
|
||||
}
|
||||
} else {
|
||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||
onchainPaying.value = false
|
||||
if (!invoiceData.value && !invoiceWaiting.value) {
|
||||
payWithInvoice()
|
||||
} else if (invoiceData.value) {
|
||||
scheduleInvoicePoll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-chain QR: ask the seller for a fresh address + amount, render a
|
||||
* `bitcoin:` QR for any external wallet, and poll the seller until the payment
|
||||
* lands, then release the file (the address is the gate token).
|
||||
*/
|
||||
async function loadOnchainQr() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
onchainError.value = ''
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainWaiting.value = true
|
||||
try {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
||||
method: 'content.request-onchain',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!req?.address || !req?.amount_sats) {
|
||||
onchainError.value = req?.error || 'The seller could not provide an on-chain address.'
|
||||
onchainWaiting.value = false
|
||||
return
|
||||
}
|
||||
onchainData.value = { address: req.address, amount_sats: req.amount_sats }
|
||||
const btc = (req.amount_sats / 1e8).toFixed(8)
|
||||
try {
|
||||
onchainQr.value = await QRCode.toDataURL(`bitcoin:${req.address}?amount=${btc}`, { margin: 1, width: 240 })
|
||||
} catch {
|
||||
onchainQr.value = '' // fall back to showing the raw address
|
||||
}
|
||||
onchainWaiting.value = false
|
||||
onchainPaying.value = true // "waiting for payment" — drives the poll loop
|
||||
pollOnchain(req.address)
|
||||
} catch (e: unknown) {
|
||||
onchainError.value = e instanceof Error ? e.message : 'Could not request an on-chain address'
|
||||
onchainWaiting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOnchain() {
|
||||
if (!onchainData.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(onchainData.value.address)
|
||||
onchainCopied.value = true
|
||||
setTimeout(() => { onchainCopied.value = false }, 1500)
|
||||
} catch { /* clipboard denied */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
|
||||
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects
|
||||
@@ -695,7 +847,7 @@ async function payOnchain() {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
||||
method: 'content.request-onchain',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!req?.address || !req?.amount_sats) {
|
||||
lnError.value = req?.error || 'The seller could not provide an on-chain address.'
|
||||
@@ -805,14 +957,14 @@ async function payWithInvoice() {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
|
||||
payMode.value = 'invoice'
|
||||
payMode.value = 'qr'
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!res?.bolt11 || !res?.payment_hash) {
|
||||
invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
@@ -849,7 +1001,7 @@ async function payWithLightning() {
|
||||
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!inv?.bolt11 || !inv?.payment_hash) {
|
||||
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
<span class="text-white/80 text-sm">FIPS Mesh</span>
|
||||
<span class="text-white/80 text-sm">Fuck IPs Mesh</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
|
||||
</div>
|
||||
|
||||
@@ -122,9 +122,7 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Cashu">🥜</span>
|
||||
<span class="text-sm text-white/80">Cashu</span>
|
||||
</div>
|
||||
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
||||
|
||||
@@ -88,6 +88,22 @@ function addPubkey() {
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
|
||||
// Radio/peers who recently tried `!ai` and were turned away by the policy.
|
||||
// Surfaced so the operator can one-click allow them instead of digging through
|
||||
// the journal for the firmware key. Hide any we've since allowed.
|
||||
const deniedAskers = computed(() =>
|
||||
(status.value?.denied_askers ?? []).filter(
|
||||
(d) => !d.pubkey_hex || !isAllowed(d.pubkey_hex),
|
||||
),
|
||||
)
|
||||
function allowDenied(pubkey: string | null) {
|
||||
if (!pubkey) return
|
||||
if (!isAllowed(pubkey)) {
|
||||
allowedContacts.value = [...allowedContacts.value, pubkey]
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mesh.fetchAssistantStatus()
|
||||
})
|
||||
@@ -138,7 +154,6 @@ function onPolicy() {
|
||||
<template>
|
||||
<div class="glass-card mesh-assistant-panel">
|
||||
<h3 class="mesh-panel-title">AI Assistant</h3>
|
||||
<p class="mesh-panel-sub">Answer questions over the mesh with AI</p>
|
||||
|
||||
<!-- Backend chooser -->
|
||||
<div class="mesh-assistant-field">
|
||||
@@ -206,10 +221,8 @@ function onPolicy() {
|
||||
<option value="trusted">Trusted nodes only</option>
|
||||
<option value="anyone">Anyone on the mesh</option>
|
||||
</select>
|
||||
<p class="text-xs text-white/40 mt-1">
|
||||
{{ policy === 'anyone'
|
||||
? 'Any peer can spend this node\'s AI budget + airtime.'
|
||||
: 'Only federation-trusted peers may ask.' }}
|
||||
<p v-if="policy === 'anyone'" class="text-xs text-white/40 mt-1">
|
||||
Any peer can spend this node's AI budget + airtime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -217,9 +230,6 @@ function onPolicy() {
|
||||
policy is "trusted only" and they aren't federation-trusted. -->
|
||||
<div class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Always allow these contacts</label>
|
||||
<p class="text-xs text-white/40 mb-2">
|
||||
Listed contacts can use <code>!ai</code> regardless of the policy above.
|
||||
</p>
|
||||
<div v-if="contactOptions.length === 0" class="text-xs text-white/40">
|
||||
No contacts yet — they appear here once you have mesh/federation contacts.
|
||||
</div>
|
||||
@@ -256,6 +266,38 @@ function onPolicy() {
|
||||
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Recently denied askers: someone tried !ai but the policy turned them
|
||||
away. Show who, and offer a one-click Allow when we know their key. -->
|
||||
<div v-if="deniedAskers.length > 0" class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Recently denied</label>
|
||||
<p class="text-xs text-white/40 mb-2">
|
||||
These tried <code>!ai</code> but the policy turned them away. Allow one to add its key.
|
||||
</p>
|
||||
<div class="mesh-assistant-allowlist">
|
||||
<div
|
||||
v-for="d in deniedAskers"
|
||||
:key="d.contact_id + (d.pubkey_hex || '')"
|
||||
class="mesh-assistant-allow-row"
|
||||
>
|
||||
<span class="mesh-assistant-allow-name" :title="d.pubkey_hex || ''">
|
||||
{{ d.name || ('#' + d.contact_id) }}
|
||||
<span v-if="d.pubkey_hex" class="text-white/30">· {{ d.pubkey_hex.slice(0, 10) }}…</span>
|
||||
</span>
|
||||
<button
|
||||
v-if="d.pubkey_hex"
|
||||
type="button"
|
||||
class="glass-button mesh-bitcoin-input-sm mesh-assistant-allow-btn"
|
||||
@click="allowDenied(d.pubkey_hex)"
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
<span v-else class="text-xs text-white/30" title="No archipelago key advertised — switch policy to 'Anyone on the mesh' to admit this device.">
|
||||
no key
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Ask from any client by sending <code>!ai <question></code> on the mesh channel.
|
||||
</p>
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
.mesh-status-indicator.connected { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.5); }
|
||||
.mesh-status-indicator.disconnected { background: rgba(255, 255, 255, 0.3); }
|
||||
.mesh-section-title { font-size: 0.95rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin: 0; }
|
||||
/* Collapse chevron — only used on mobile (hidden on desktop, where the Device
|
||||
panel is always expanded). Kept small and pushed to the far right. */
|
||||
.mesh-status-chevron { display: none; width: 16px; height: 16px; margin-left: auto; flex-shrink: 0; color: rgba(255, 255, 255, 0.5); transition: transform 0.2s ease; }
|
||||
.mesh-status-card:not(.mesh-status-collapsed) .mesh-status-chevron { transform: rotate(180deg); }
|
||||
.mesh-status-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.mesh-stat { display: flex; flex-direction: column; gap: 1px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; }
|
||||
.mesh-stat-label { font-size: 0.65rem; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
@@ -90,7 +94,7 @@
|
||||
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 30px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
|
||||
.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); }
|
||||
.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); }
|
||||
.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); width: 20px; height: 20px; line-height: 18px; text-align: center; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 16px; cursor: pointer; padding: 0; }
|
||||
.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; line-height: 1; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 15px; cursor: pointer; padding: 0; }
|
||||
.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; }
|
||||
.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; }
|
||||
.mesh-peer-reach.is-reachable { background: #34d399; }
|
||||
@@ -120,7 +124,9 @@
|
||||
.mesh-chat-empty p { margin: 0; font-size: 0.9rem; }
|
||||
.mesh-chat-empty-sub { font-size: 0.75rem !important; color: rgba(255, 255, 255, 0.2); }
|
||||
.mesh-chat-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
|
||||
.mesh-chat-back { background: none; border: none; color: rgba(255, 255, 255, 0.6); font-size: 1.2rem; cursor: pointer; padding: 4px 8px; border-radius: 6px; display: none; }
|
||||
/* Floating mobile back button (Teleported to body). Hidden by default; only
|
||||
shown in the single-column mobile mesh layout (see media query below). */
|
||||
.mesh-chat-mobile-back { display: none; }
|
||||
.mesh-chat-header-info { flex: 1; min-width: 0; }
|
||||
.mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
|
||||
.mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; }
|
||||
@@ -155,22 +161,115 @@
|
||||
@keyframes mesh-send-spin { to { transform: rotate(360deg); } }
|
||||
.mesh-mobile-back-btn { display: none; }
|
||||
|
||||
/* Floating mobile mesh tab strip (Teleported to body). Hidden on desktop; the
|
||||
≤1279px block flips it to flex and the placement mirrors the mobile back
|
||||
button (pinned above the global tab bar + audio player). */
|
||||
.mesh-mobile-tabbar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
|
||||
z-index: 40;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.mesh-mtab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.mesh-mtab:hover { color: rgba(255, 255, 255, 0.9); }
|
||||
.mesh-mtab.active { background: rgba(251, 146, 60, 0.2); color: #fff; }
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.mesh-view { height: auto; overflow: visible; padding: 0 12px 100px 12px; }
|
||||
.mesh-columns { flex-direction: column; overflow: visible; }
|
||||
.mesh-left { width: 100%; overflow: visible; }
|
||||
.mesh-right { min-height: auto; overflow: visible; }
|
||||
.mesh-chat-card { min-height: 60dvh; max-height: 75dvh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Single-column mobile mesh: one fixed, internally-scrolling pane that
|
||||
fills the space between the top tab strip and the floating mesh tab bar.
|
||||
The page itself never scrolls; each pane scrolls inside its own bounds.
|
||||
Fixed positioning is relative to the full-height perspective container, so
|
||||
the offsets line up with the body-teleported tab bar / back button. ──── */
|
||||
.mesh-left,
|
||||
.mesh-mobile-tools,
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
/* width:auto so left+right govern the box — .mesh-left otherwise carries a
|
||||
fixed 380px width that ignores `right` and overflows the screen. */
|
||||
width: auto;
|
||||
box-sizing: border-box;
|
||||
top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 96px);
|
||||
/* Just above the floating mesh tab bar (tabs sit at +8, ~48px tall). */
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 72px);
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
z-index: 30;
|
||||
}
|
||||
/* Active conversation: the floating tabs are hidden here and the back button
|
||||
takes the standard spot above the tab bar, so the chat window fills down to
|
||||
just above the back pill (back pill ≈44px at +8, plus a 16px gap). When the
|
||||
keyboard is up it covers the tab bar, so anchor to whichever is taller — the
|
||||
bottom controls or the keyboard — so the window sits right above both. */
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
|
||||
overflow: hidden; /* the messages list inside does the scrolling */
|
||||
}
|
||||
.mesh-tools-wrapper { display: none !important; }
|
||||
.mesh-mobile-tools { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.mesh-mobile-tools .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; }
|
||||
.mesh-mobile-tools :deep(.mesh-bitcoin-panel),
|
||||
.mesh-mobile-tools :deep(.mesh-assistant-panel),
|
||||
.mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; max-height: min(68dvh, 620px); overflow-y: auto; }
|
||||
.mesh-mobile-tools .mesh-map-panel { min-height: 360px; max-height: min(68dvh, 620px); overflow: hidden; }
|
||||
.mesh-mobile-tools { margin-top: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
/* The active tool fills the whole fixed pane (no fixed cap that would leave a
|
||||
bottom margin); the panel itself scrolls if its content is taller. */
|
||||
.mesh-mobile-tools > * { flex: 1 1 auto; min-height: 0; max-height: none; }
|
||||
.mesh-mobile-tools .mesh-bitcoin-panel,
|
||||
.mesh-mobile-tools .mesh-assistant-panel,
|
||||
.mesh-mobile-tools .mesh-deadman-panel { overflow-y: auto; }
|
||||
.mesh-mobile-tools .mesh-map-panel { height: 100%; overflow: hidden; }
|
||||
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.mesh-chat-back { display: block; }
|
||||
/* In a conversation the tabs are hidden, so the back pill sits just above the
|
||||
tab bar — or above the keyboard when it's up, whichever is taller. */
|
||||
.mesh-chat-mobile-back { display: flex; }
|
||||
.mesh-chat-mobile-back.mobile-back-btn {
|
||||
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
|
||||
}
|
||||
/* Floating mesh tab strip — same placement logic as the mobile back button. */
|
||||
.mesh-mobile-tabbar { display: flex; }
|
||||
.mobile-hidden { display: none !important; }
|
||||
/* Device panel is a collapsible/expandable accordion on mobile (starts
|
||||
collapsed). Show the chevron, make the header tappable, and hide the body
|
||||
when collapsed. */
|
||||
.mesh-status-chevron { display: block; }
|
||||
.mesh-status-card .mesh-status-header { cursor: pointer; margin-bottom: 12px; }
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-status-header { margin-bottom: 0; }
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-status-grid,
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-detected-devices { display: none; }
|
||||
:deep(.mesh-bitcoin-panel),
|
||||
:deep(.mesh-assistant-panel),
|
||||
:deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; }
|
||||
@@ -181,6 +280,14 @@
|
||||
.mesh-view {
|
||||
padding: 24px;
|
||||
}
|
||||
/* In this range the desktop sidebar (256px) is still shown. The in-pane
|
||||
fixed elements are positioned relative to the main content area (their
|
||||
perspective containing block), so they already clear the sidebar — but the
|
||||
body-teleported floating bars are viewport-relative, so nudge them right. */
|
||||
.mesh-mobile-tabbar,
|
||||
.mesh-chat-mobile-back.mobile-back-btn {
|
||||
left: 268px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2>
|
||||
<h2 class="text-xl font-semibold text-white">Fuck IPs Mesh</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2" :title="statusLabel">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
||||
|
||||
Reference in New Issue
Block a user