feat(federation): 2D/3D projection toggle + interaction fixes + mobile layout polish
- Small glass 2D/3D toggle top-right of the map: tweens tilt/perspective/fit
(0.9s inOut) so the scene folds flat into the original radial 2D layout and
back. Default: portrait/mobile opens 2D, desktop 3D; last choice saved to
localStorage.
- Fix: setPointerCapture retargeted pointerup to the container, suppressing
click synthesis on children — node taps and the toggle never fired. Drag
now tracks via window listeners, no capture.
- Fix: stale post-drag distance made the click-suppressor swallow toggle taps
indefinitely ('stuck' toggle). Distance resets on every pointerdown and the
suppressor is one-shot.
- Map no longer slides under the floating mobile back pill: the
mobile-scroll-pad-back panel keeps its full 64px clearance when filled.
- Mobile DID copy/rotate card moved out of the header to below the view tabs
(new DidCardMobile.vue) and hidden on the Network Map tab.
- Node labels: dark stroke halo removed; 10px on mobile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
aea90984d6
commit
e0aebf50f8
@@ -13,6 +13,19 @@
|
|||||||
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span>
|
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span>
|
||||||
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span>
|
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 2D/3D projection toggle — tweens the camera between the flat radial
|
||||||
|
layout and the depth view -->
|
||||||
|
<div class="node-map-mode-toggle" role="group" aria-label="Map projection">
|
||||||
|
<button
|
||||||
|
v-for="m in (['2d', '3d'] as const)"
|
||||||
|
:key="m"
|
||||||
|
class="node-map-mode-btn"
|
||||||
|
:class="{ 'node-map-mode-btn-active': viewMode === m }"
|
||||||
|
:aria-pressed="viewMode === m"
|
||||||
|
@click="setMode(m)"
|
||||||
|
>{{ m.toUpperCase() }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="peerCount === 0" class="node-map-empty">
|
<div v-if="peerCount === 0" class="node-map-empty">
|
||||||
<p class="node-map-empty-title">No peers yet</p>
|
<p class="node-map-empty-title">No peers yet</p>
|
||||||
<p class="node-map-empty-sub">Invite a peer or discover nodes to grow your federation</p>
|
<p class="node-map-empty-sub">Invite a peer or discover nodes to grow your federation</p>
|
||||||
@@ -62,12 +75,21 @@ const SVG_NS = 'http://www.w3.org/2000/svg'
|
|||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
const cam = {
|
const cam = {
|
||||||
rotY: -1.1, // intro starts swung around; settles to 0
|
rotY: -1.1, // intro starts swung around; settles to 0
|
||||||
tilt: -0.5, // fixed downward tilt so the orbit reads as a plane
|
tilt: -0.5, // downward tilt; tweened between the 2D and 3D projections
|
||||||
|
persp: 3.2, // perspective strength; large = near-orthographic (flat 2D)
|
||||||
zoom: 0.82, // intro dollies in to 1
|
zoom: 0.82, // intro dollies in to 1
|
||||||
spin: 0, // rad/s — only ever non-zero from drag inertia; the scene does not
|
spin: 0, // rad/s — only ever non-zero from drag inertia; the scene does not
|
||||||
// idle-orbit (nodes hold position so the map stays readable)
|
// idle-orbit (nodes hold position so the map stays readable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The two projections the top-right toggle tweens between: '2d' is the
|
||||||
|
* original flat radial map (top-down, orthographic); '3d' is the depth view. */
|
||||||
|
const MODES = {
|
||||||
|
'2d': { tilt: -1.45, persp: 18 },
|
||||||
|
'3d': { tilt: -0.5, persp: 3.2 },
|
||||||
|
} as const
|
||||||
|
type MapMode = keyof typeof MODES
|
||||||
|
|
||||||
/** One dot on a node's point-cloud sphere, in unit-sphere local coords. */
|
/** One dot on a node's point-cloud sphere, in unit-sphere local coords. */
|
||||||
interface GlobeDot {
|
interface GlobeDot {
|
||||||
el: SVGCircleElement
|
el: SVGCircleElement
|
||||||
@@ -104,16 +126,17 @@ let ringsLayer: SVGGElement | null = null
|
|||||||
|
|
||||||
let width = 0
|
let width = 0
|
||||||
let height = 0
|
let height = 0
|
||||||
let unit = 1
|
/** Scene scale + vertical centring — tweened alongside cam.tilt/persp during
|
||||||
let centerY = 0
|
* a 2D/3D toggle so the layout re-fits as it folds. */
|
||||||
|
const fit = { unit: 1, centerY: 0 }
|
||||||
let elapsed = 0
|
let elapsed = 0
|
||||||
let frame = 0
|
let frame = 0
|
||||||
let selfDots: GlobeDot[] = []
|
let selfDots: GlobeDot[] = []
|
||||||
let selfRadiusPx = 15
|
let selfRadiusPx = 15
|
||||||
|
let modeTween: gsap.core.Tween[] = []
|
||||||
|
let modeInitialized = false
|
||||||
|
|
||||||
/** Perspective strength — measure() sets it per form factor: 3.2 gives the
|
const viewMode = ref<MapMode>('3d')
|
||||||
* desktop scene real depth; a large value is near-orthographic (flat). */
|
|
||||||
let persp = 3.2
|
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
let intro: gsap.core.Timeline | null = null
|
let intro: gsap.core.Timeline | null = null
|
||||||
let spinTween: gsap.core.Tween | null = null
|
let spinTween: gsap.core.Tween | null = null
|
||||||
@@ -157,10 +180,10 @@ function project(x: number, y: number, z: number) {
|
|||||||
const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt)
|
const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt)
|
||||||
const y2 = y * ct - z1 * st
|
const y2 = y * ct - z1 * st
|
||||||
const z2 = y * st + z1 * ct
|
const z2 = y * st + z1 * ct
|
||||||
const s = persp / (persp + z2)
|
const s = cam.persp / (cam.persp + z2)
|
||||||
return {
|
return {
|
||||||
x: width / 2 + x1 * unit * s * cam.zoom,
|
x: width / 2 + x1 * fit.unit * s * cam.zoom,
|
||||||
y: centerY + y2 * unit * s * cam.zoom,
|
y: fit.centerY + y2 * fit.unit * s * cam.zoom,
|
||||||
s,
|
s,
|
||||||
z: z2,
|
z: z2,
|
||||||
}
|
}
|
||||||
@@ -511,12 +534,21 @@ let dragDistance = 0
|
|||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
if (staticMode) return
|
if (staticMode) return
|
||||||
|
// Reset BEFORE the toggle early-return: a stale post-drag distance would
|
||||||
|
// otherwise make onClickCapture swallow toggle taps forever ("stuck" toggle)
|
||||||
|
dragDistance = 0
|
||||||
|
// Never hijack the projection toggle's taps
|
||||||
|
if ((e.target as Element | null)?.closest?.('.node-map-mode-toggle')) return
|
||||||
dragging = true
|
dragging = true
|
||||||
lastX = e.clientX
|
lastX = e.clientX
|
||||||
dragVel = 0
|
dragVel = 0
|
||||||
dragDistance = 0
|
dragDistance = 0
|
||||||
spinTween?.kill()
|
spinTween?.kill()
|
||||||
containerRef.value?.setPointerCapture(e.pointerId)
|
// Deliberately NO setPointerCapture: capture retargets pointerup to the
|
||||||
|
// container, which suppresses the browser's click synthesis on child
|
||||||
|
// elements — it silently killed node taps and the 2D/3D toggle. Drag
|
||||||
|
// continuity outside the container comes from window-level move/up
|
||||||
|
// listeners instead.
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(e: PointerEvent) {
|
function onPointerMove(e: PointerEvent) {
|
||||||
@@ -534,13 +566,14 @@ function onClickCapture(e: MouseEvent) {
|
|||||||
if (dragDistance > 6) {
|
if (dragDistance > 6) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
// One-shot: only the click synthesized from THIS drag is swallowed
|
||||||
|
dragDistance = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(e: PointerEvent) {
|
function onPointerUp(_e: PointerEvent) {
|
||||||
if (!dragging) return
|
if (!dragging) return
|
||||||
dragging = false
|
dragging = false
|
||||||
try { containerRef.value?.releasePointerCapture(e.pointerId) } catch { /* already released */ }
|
|
||||||
// Inertia: carry the fling velocity, then settle to a full stop — the
|
// Inertia: carry the fling velocity, then settle to a full stop — the
|
||||||
// scene never idle-orbits on its own.
|
// scene never idle-orbits on its own.
|
||||||
cam.spin = Math.max(-3, Math.min(3, dragVel))
|
cam.spin = Math.max(-3, Math.min(3, dragVel))
|
||||||
@@ -549,28 +582,15 @@ function onPointerUp(e: PointerEvent) {
|
|||||||
|
|
||||||
/* ----------------------------- lifecycle --------------------------- */
|
/* ----------------------------- lifecycle --------------------------- */
|
||||||
|
|
||||||
function measure() {
|
/** Auto-fit for a given projection: sample the outermost orbit through the
|
||||||
const c = containerRef.value
|
* camera math (rotation-invariant — a ring about the Y axis projects
|
||||||
if (!c) return
|
* identically at any rotY) to get the scene's true projected bounds, then
|
||||||
width = c.clientWidth
|
* scale to fill the container and centre vertically between the overlays.
|
||||||
height = c.clientHeight
|
* Pure with respect to tilt/persp so a mode toggle can compute its target
|
||||||
svgRef.value?.setAttribute('viewBox', `0 0 ${width} ${height}`)
|
* fit and tween towards it. */
|
||||||
|
function computeFit(tilt: number, perspVal: number): { unit: number; centerY: number } {
|
||||||
// Portrait (phone/companion): go near top-down and near-orthographic so
|
|
||||||
// the map reads like the old 2D radial view — rings become circles, no
|
|
||||||
// perspective squash, maximum label spread. Desktop keeps the 3D depth.
|
|
||||||
const portrait = height > width
|
|
||||||
cam.tilt = portrait ? -1.35 : -0.5
|
|
||||||
persp = portrait ? 16 : 3.2
|
|
||||||
|
|
||||||
// Auto-fit: sample the outermost orbit through the real camera math
|
|
||||||
// (rotation-invariant — a ring about the Y axis projects identically at any
|
|
||||||
// rotY) to get the scene's true projected bounds, then scale to fill the
|
|
||||||
// container and centre vertically in the space between the overlays. This
|
|
||||||
// is what makes the map fill a phone, the companion, and a desktop panel
|
|
||||||
// equally well instead of assuming one aspect ratio.
|
|
||||||
const maxRing = ringPaths.length ? Math.max(...ringPaths.map(r => r.radius)) : 1
|
const maxRing = ringPaths.length ? Math.max(...ringPaths.map(r => r.radius)) : 1
|
||||||
const ct = Math.cos(cam.tilt), st = Math.sin(cam.tilt)
|
const ct = Math.cos(tilt), st = Math.sin(tilt)
|
||||||
let maxAbsX = 0
|
let maxAbsX = 0
|
||||||
let yMin = Infinity
|
let yMin = Infinity
|
||||||
let yMax = -Infinity
|
let yMax = -Infinity
|
||||||
@@ -581,22 +601,68 @@ function measure() {
|
|||||||
for (const y of [-0.2, 0.2]) { // covers yJitter + sway/bob amplitude
|
for (const y of [-0.2, 0.2]) { // covers yJitter + sway/bob amplitude
|
||||||
const y2 = y * ct - z * st
|
const y2 = y * ct - z * st
|
||||||
const z2 = y * st + z * ct
|
const z2 = y * st + z * ct
|
||||||
const s = persp / (persp + z2)
|
const s = perspVal / (perspVal + z2)
|
||||||
maxAbsX = Math.max(maxAbsX, Math.abs(x * s))
|
maxAbsX = Math.max(maxAbsX, Math.abs(x * s))
|
||||||
yMin = Math.min(yMin, y2 * s)
|
yMin = Math.min(yMin, y2 * s)
|
||||||
yMax = Math.max(yMax, y2 * s)
|
yMax = Math.max(yMax, y2 * s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const marginX = 46 // node radius + label half-width clearance
|
const marginX = 46 // node radius + label half-width clearance
|
||||||
const marginTop = 54 // legend chip
|
const marginTop = 54 // legend / toggle chips
|
||||||
const marginBottom = 66 // hint line + node label below the front edge
|
const marginBottom = 66 // hint line + node label below the front edge
|
||||||
const bandH = Math.max(80, height - marginTop - marginBottom)
|
const bandH = Math.max(80, height - marginTop - marginBottom)
|
||||||
const unitX = (width / 2 - marginX) / maxAbsX
|
const unitX = (width / 2 - marginX) / maxAbsX
|
||||||
const unitY = bandH / Math.max(yMax - yMin, 0.001)
|
const unitY = bandH / Math.max(yMax - yMin, 0.001)
|
||||||
unit = Math.max(20, Math.min(unitX, unitY))
|
const u = Math.max(20, Math.min(unitX, unitY))
|
||||||
// Place the projected ellipse's midpoint at the centre of the available band
|
// Place the projected ellipse's midpoint at the centre of the available band
|
||||||
centerY = marginTop + bandH / 2 - ((yMax + yMin) / 2) * unit
|
return { unit: u, centerY: marginTop + bandH / 2 - ((yMax + yMin) / 2) * u }
|
||||||
render()
|
}
|
||||||
|
|
||||||
|
/** Snap (or tween, via setMode) the camera + fit to the given mode. */
|
||||||
|
function applyMode(mode: MapMode, animate: boolean) {
|
||||||
|
const target = MODES[mode]
|
||||||
|
const targetFit = computeFit(target.tilt, target.persp)
|
||||||
|
for (const t of modeTween) t.kill()
|
||||||
|
modeTween = []
|
||||||
|
if (animate && !staticMode) {
|
||||||
|
const opts = { duration: 0.9, ease: motionTokens.ease.inOut }
|
||||||
|
modeTween.push(
|
||||||
|
gsap.to(cam, { tilt: target.tilt, persp: target.persp, ...opts }),
|
||||||
|
gsap.to(fit, { ...targetFit, ...opts }),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cam.tilt = target.tilt
|
||||||
|
cam.persp = target.persp
|
||||||
|
fit.unit = targetFit.unit
|
||||||
|
fit.centerY = targetFit.centerY
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(mode: MapMode) {
|
||||||
|
if (viewMode.value === mode) return
|
||||||
|
viewMode.value = mode
|
||||||
|
try { localStorage.setItem('federation-map-projection', mode) } catch { /* private mode */ }
|
||||||
|
applyMode(mode, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function measure() {
|
||||||
|
const c = containerRef.value
|
||||||
|
if (!c) return
|
||||||
|
width = c.clientWidth
|
||||||
|
height = c.clientHeight
|
||||||
|
svgRef.value?.setAttribute('viewBox', `0 0 ${width} ${height}`)
|
||||||
|
|
||||||
|
// First measurement decides the default projection: saved preference wins,
|
||||||
|
// otherwise portrait containers (phone/companion) open in the flat 2D view.
|
||||||
|
if (!modeInitialized) {
|
||||||
|
modeInitialized = true
|
||||||
|
let saved: string | null = null
|
||||||
|
try { saved = localStorage.getItem('federation-map-projection') } catch { /* private mode */ }
|
||||||
|
viewMode.value = saved === '2d' || saved === '3d' ? saved : (height > width ? '2d' : '3d')
|
||||||
|
}
|
||||||
|
// Resize: snap to the current mode's fit (no tween — tracks the drag)
|
||||||
|
applyMode(viewMode.value, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const graphSignature = computed(() => JSON.stringify({
|
const graphSignature = computed(() => JSON.stringify({
|
||||||
@@ -633,10 +699,11 @@ onMounted(() => {
|
|||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
resizeObserver.observe(containerRef.value)
|
resizeObserver.observe(containerRef.value)
|
||||||
containerRef.value.addEventListener('pointerdown', onPointerDown)
|
containerRef.value.addEventListener('pointerdown', onPointerDown)
|
||||||
containerRef.value.addEventListener('pointermove', onPointerMove)
|
|
||||||
containerRef.value.addEventListener('pointerup', onPointerUp)
|
|
||||||
containerRef.value.addEventListener('pointercancel', onPointerUp)
|
|
||||||
containerRef.value.addEventListener('click', onClickCapture, true)
|
containerRef.value.addEventListener('click', onClickCapture, true)
|
||||||
|
// Window-level so a drag that leaves the container keeps tracking
|
||||||
|
window.addEventListener('pointermove', onPointerMove)
|
||||||
|
window.addEventListener('pointerup', onPointerUp)
|
||||||
|
window.addEventListener('pointercancel', onPointerUp)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -644,7 +711,11 @@ onUnmounted(() => {
|
|||||||
detachTicker()
|
detachTicker()
|
||||||
intro?.kill()
|
intro?.kill()
|
||||||
spinTween?.kill()
|
spinTween?.kill()
|
||||||
|
for (const t of modeTween) t.kill()
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
|
window.removeEventListener('pointermove', onPointerMove)
|
||||||
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
|
window.removeEventListener('pointercancel', onPointerUp)
|
||||||
})
|
})
|
||||||
|
|
||||||
// KeepAlive-aware: pause the 60fps loop while the tab is cached, resume on return
|
// KeepAlive-aware: pause the 60fps loop while the tab is cached, resume on return
|
||||||
@@ -712,6 +783,38 @@ watch(graphSignature, () => rebuild(false))
|
|||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
.node-map-mode-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.node-map-mode-btn {
|
||||||
|
/* min-height !important: the global ≤767px 44px touch-target rule would
|
||||||
|
deform this deliberately-compact control */
|
||||||
|
min-height: 0 !important;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
transition: color 0.2s ease, background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.node-map-mode-btn:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
.node-map-mode-btn-active {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
.node-map-hint {
|
.node-map-hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
@@ -760,16 +863,9 @@ watch(graphSignature, () => rebuild(false))
|
|||||||
<!-- Unscoped on purpose: the SVG labels are created with createElementNS at
|
<!-- Unscoped on purpose: the SVG labels are created with createElementNS at
|
||||||
runtime, so Vue's scoped-style data attributes never land on them. -->
|
runtime, so Vue's scoped-style data attributes never land on them. -->
|
||||||
<style>
|
<style>
|
||||||
/* Dark halo behind node labels — keeps them legible over dots and links */
|
|
||||||
.node-map-stage text.nm-label {
|
|
||||||
paint-order: stroke;
|
|
||||||
stroke: rgba(0, 0, 0, 0.6);
|
|
||||||
stroke-width: 2.5px;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.node-map-stage text.nm-label {
|
.node-map-stage text.nm-label {
|
||||||
font-size: 12.5px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3187,3 +3187,12 @@ select::-ms-expand {
|
|||||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
|
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pages with the floating mobile back button (.mobile-scroll-pad-back) keep
|
||||||
|
its full clearance under the filled map so the stage never slides beneath
|
||||||
|
the button. */
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.dashboard-scroll-panel.mobile-scroll-pad-back:has(.node-map-stage) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile DID card: below the tabs per UX; hidden on the map tab where
|
||||||
|
vertical space belongs to the map (desktop keeps the header card) -->
|
||||||
|
<DidCardMobile
|
||||||
|
v-if="!mapActive"
|
||||||
|
:self-did="selfDid"
|
||||||
|
:server-name="appStore.serverName"
|
||||||
|
@rotate="showRotateModal = true"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Network Map View — fills all remaining height to the bottom edge -->
|
<!-- Network Map View — fills all remaining height to the bottom edge -->
|
||||||
<div v-if="mapActive" class="flex-1 min-h-0">
|
<div v-if="mapActive" class="flex-1 min-h-0">
|
||||||
<NetworkMap3D :nodes="mapNodes" :links="mapLinks" @select="onMapSelect" />
|
<NetworkMap3D :nodes="mapNodes" :links="mapLinks" @select="onMapSelect" />
|
||||||
@@ -250,6 +259,7 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { useSyncStore } from '@/stores/sync'
|
import { useSyncStore } from '@/stores/sync'
|
||||||
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
|
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
|
||||||
import FederationHeader from './federation/FederationHeader.vue'
|
import FederationHeader from './federation/FederationHeader.vue'
|
||||||
|
import DidCardMobile from './federation/DidCardMobile.vue'
|
||||||
import RotateDidModal from './federation/RotateDidModal.vue'
|
import RotateDidModal from './federation/RotateDidModal.vue'
|
||||||
import QuickActions from './federation/QuickActions.vue'
|
import QuickActions from './federation/QuickActions.vue'
|
||||||
import NodeList from './federation/NodeList.vue'
|
import NodeList from './federation/NodeList.vue'
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Mobile-only DID copy/rotate card. Lives BELOW the view tabs in
|
||||||
|
Federation.vue (not in the header) and is hidden by the parent on the
|
||||||
|
Network Map tab, where vertical space belongs to the map. -->
|
||||||
|
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mb-6 flex items-center gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
||||||
|
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
||||||
|
</div>
|
||||||
|
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
||||||
|
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { shortDid } from './utils'
|
||||||
|
import { safeClipboardWrite } from '../web5/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selfDid: string
|
||||||
|
serverName: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
rotate: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const didCopied = ref(false)
|
||||||
|
const shortDidDisplay = computed(() => shortDid(props.selfDid))
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
if (props.selfDid) {
|
||||||
|
safeClipboardWrite(props.selfDid)
|
||||||
|
didCopied.value = true
|
||||||
|
setTimeout(() => { didCopied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -18,15 +18,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile: DID below title -->
|
<!-- Mobile DID card moved to DidCardMobile.vue, rendered by
|
||||||
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 flex items-center gap-3">
|
Federation.vue below the view tabs (hidden on the map tab). -->
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
|
||||||
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
|
||||||
</div>
|
|
||||||
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
|
||||||
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user