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:
archipelago
2026-06-19 09:52:26 -04:00
co-authored by Claude Opus 4.8
parent c4855526fe
commit 1bce694ebb
37 changed files with 1260 additions and 208 deletions
+35 -4
View File
@@ -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);
+3
View File
@@ -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'