merge: ux-at-last — federation network map (3D orbital, 2D toggle, live peer requests)
Branch stays alive for continued UX work; fast-forwarded to main after this merge so it continues from the current tip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+7
@@ -16,6 +16,7 @@
|
||||
"dompurify": "^3.3.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"gsap": "^3.15.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
@@ -7293,6 +7294,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
||||
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"dompurify": "^3.3.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"gsap": "^3.15.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// live D3 force simulation does not hold for this codebase — a full grep for
|
||||
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
|
||||
// Mesh.vue's component tree (or MeshMap.vue's); the only D3 force simulation
|
||||
// in the codebase belongs to NetworkMap.vue (Federation.vue's graph, out of
|
||||
// in the codebase belongs to NetworkMap3D.vue (Federation.vue's graph, out of
|
||||
// this plan's scope). This file therefore only covers the Leaflet map's
|
||||
// activate/deactivate lifecycle — the D3-specific truths from the plan are
|
||||
// vacuously satisfied (there is nothing to leak).
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="network-map-container">
|
||||
<svg ref="svgRef" class="w-full h-full"></svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
interface MapNode {
|
||||
did: string
|
||||
label: string
|
||||
trust_level: 'trusted' | 'observer' | 'untrusted'
|
||||
online: boolean
|
||||
app_count: number
|
||||
is_self: boolean
|
||||
}
|
||||
|
||||
interface MapLink {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: MapNode[]
|
||||
links: MapLink[]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const svgRef = ref<SVGSVGElement>()
|
||||
|
||||
type SimNode = MapNode & d3.SimulationNodeDatum
|
||||
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
|
||||
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const graphSignature = computed(() => JSON.stringify({
|
||||
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
|
||||
links: props.links.map(l => [l.source, l.target]),
|
||||
}))
|
||||
|
||||
function trustColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'trusted': return '#4ade80'
|
||||
case 'observer': return '#fb923c'
|
||||
case 'untrusted': return '#ef4444'
|
||||
default: return '#9ca3af'
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRadius(n: MapNode): number {
|
||||
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
|
||||
}
|
||||
|
||||
function render() {
|
||||
simulation?.stop()
|
||||
const svg = d3.select(svgRef.value!)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const container = containerRef.value!
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
|
||||
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
|
||||
|
||||
// Center the self-node
|
||||
const selfNode = simNodes.find(n => n.is_self)
|
||||
if (selfNode) {
|
||||
selfNode.fx = width / 2
|
||||
selfNode.fy = height / 2
|
||||
}
|
||||
|
||||
simulation = d3.forceSimulation(simNodes)
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
// Links
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(simLinks)
|
||||
.join('line')
|
||||
.attr('stroke', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
|
||||
})
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-dasharray', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
|
||||
})
|
||||
|
||||
// Node groups
|
||||
const node = g.append('g')
|
||||
.selectAll<SVGGElement, SimNode>('g')
|
||||
.data(simNodes)
|
||||
.join('g')
|
||||
.attr('cursor', 'pointer')
|
||||
.call(d3.drag<SVGGElement, SimNode>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0)
|
||||
if (!d.is_self) { d.fx = null; d.fy = null }
|
||||
})
|
||||
)
|
||||
|
||||
// Node circles
|
||||
node.append('circle')
|
||||
.attr('r', d => nodeRadius(d))
|
||||
.attr('fill', d => trustColor(d.trust_level))
|
||||
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
|
||||
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
|
||||
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
|
||||
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
|
||||
|
||||
// Node labels
|
||||
node.append('text')
|
||||
.text(d => d.label)
|
||||
.attr('dy', d => nodeRadius(d) + 14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'rgba(255,255,255,0.7)')
|
||||
.attr('font-size', '11px')
|
||||
.attr('font-family', "'Avenir Next', sans-serif")
|
||||
|
||||
// Tooltip
|
||||
node.append('title')
|
||||
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => (d.source as SimNode).x!)
|
||||
.attr('y1', d => (d.source as SimNode).y!)
|
||||
.attr('x2', d => (d.target as SimNode).x!)
|
||||
.attr('y2', d => (d.target as SimNode).y!)
|
||||
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
resizeObserver = new ResizeObserver(() => render())
|
||||
if (containerRef.value) resizeObserver.observe(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
simulation?.stop()
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(graphSignature, () => render())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-container {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3149,3 +3149,50 @@ select {
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Federation 3D node map — fill-to-bottom layout
|
||||
When the map stage is on screen, the dashboard scroll panel switches from
|
||||
a scrolling document to a column that hands all remaining height to the
|
||||
map, killing the big bottom margin on every form factor. List view (no
|
||||
.node-map-stage in the DOM) is untouched, and browsers without :has()
|
||||
gracefully fall back to the old scrolling behaviour via the stage's
|
||||
min-height.
|
||||
========================================================================= */
|
||||
.dashboard-scroll-panel:has(.node-map-stage) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Desktop: trim the 6rem .mobile-scroll-pad breathing room to a slim edge */
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* The routed view stretches; DashboardRouterView tags it .view-container
|
||||
(with Tailwind's flex-none, which this outranks on specificity). */
|
||||
.dashboard-scroll-panel:has(.node-map-stage) > .view-container {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The wrapper's bottom scroll spacer is dead weight in a filled column */
|
||||
.dashboard-scroll-panel:has(.node-map-stage) > div[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile/tablet: fill down to the tab bar (+ audio player / safe area),
|
||||
not under it — the bar is viewport-fixed and would cover the map. */
|
||||
@media (max-width: 920px) {
|
||||
.dashboard-scroll-panel: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) + 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Design-system-aware GSAP setup — the single place animation code pulls
|
||||
* timing, easing, and colour tokens from, so every GSAP-driven surface moves
|
||||
* (and is coloured) like the rest of the glass UI instead of inventing its
|
||||
* own physics per component.
|
||||
*
|
||||
* Usage: `import { gsap, motionTokens, prefersReducedMotion } from '@/utils/motion'`
|
||||
* — never `import gsap from 'gsap'` directly, or the shared defaults are lost.
|
||||
*/
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
/** Colour tokens mirrored from style.css / tailwind.config.js. The UI is
|
||||
* dark-only (style.css pins `color-scheme: dark`), so these are constants,
|
||||
* not theme-dependent lookups. */
|
||||
export const motionTokens = {
|
||||
color: {
|
||||
/** Brand accent — the orange used for focus glows and highlights
|
||||
* (tailwind orange-400, e.g. `.glass-button:focus-visible`). */
|
||||
accent: '#fb923c',
|
||||
/** Trust-level palette — matches NodeList / trust badges. */
|
||||
trusted: '#4ade80',
|
||||
observer: '#fb923c',
|
||||
untrusted: '#ef4444',
|
||||
neutral: '#9ca3af',
|
||||
/** Pending/attention — inbound peer requests awaiting a decision. */
|
||||
pending: '#facc15',
|
||||
/** Text/line opacities on the dark glass ground. */
|
||||
textPrimary: 'rgba(255, 255, 255, 0.95)',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.7)',
|
||||
textFaint: 'rgba(255, 255, 255, 0.45)',
|
||||
line: 'rgba(255, 255, 255, 0.18)',
|
||||
lineFaint: 'rgba(255, 255, 255, 0.08)',
|
||||
glassDark: 'rgba(0, 0, 0, 0.35)',
|
||||
glassDarker: 'rgba(0, 0, 0, 0.6)',
|
||||
},
|
||||
/** Durations (seconds) — align with the CSS transitions already shipped
|
||||
* (modal 0.3s, press feedback 0.1s). */
|
||||
duration: {
|
||||
fast: 0.18,
|
||||
base: 0.3,
|
||||
slow: 0.6,
|
||||
/** Scene-setting intros (map fly-in, hero moments). */
|
||||
cinematic: 1.4,
|
||||
},
|
||||
ease: {
|
||||
/** Default UI ease — matches the snappy glass feel. */
|
||||
out: 'power3.out',
|
||||
inOut: 'power2.inOut',
|
||||
/** Playful overshoot for elements "arriving" (node pop-ins). */
|
||||
arrive: 'back.out(1.6)',
|
||||
/** Springy attention pulse. */
|
||||
pulse: 'sine.inOut',
|
||||
},
|
||||
} as const
|
||||
|
||||
// Shared defaults: any tween that doesn't say otherwise moves like the rest
|
||||
// of the design system.
|
||||
gsap.defaults({
|
||||
ease: motionTokens.ease.out,
|
||||
duration: motionTokens.duration.base,
|
||||
})
|
||||
|
||||
/** Live reduced-motion check. Query at animation-build time (not module
|
||||
* scope) so OS-level toggles apply without a reload. Callers should skip
|
||||
* intros / idle loops and jump to end state when this is true. */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined'
|
||||
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
|
||||
}
|
||||
|
||||
export { gsap }
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- Map view: no pb-6 — the .dashboard-scroll-panel:has(.node-map-stage)
|
||||
rules turn this view into a column that hands remaining height to the
|
||||
map, so bottom padding would just re-create the dead margin. -->
|
||||
<div :class="mapActive ? undefined : 'pb-6'">
|
||||
<FederationHeader
|
||||
:self-did="selfDid"
|
||||
:server-name="appStore.serverName"
|
||||
@@ -16,7 +19,9 @@
|
||||
/>
|
||||
|
||||
<!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
|
||||
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto">
|
||||
<!-- md:self-start: in map view the root is a flex column, and stretch
|
||||
alignment would otherwise pull the pill full-width on desktop -->
|
||||
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto md:self-start">
|
||||
<button
|
||||
v-for="tab in viewTabs"
|
||||
:key="tab.id"
|
||||
@@ -30,9 +35,25 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Network Map View -->
|
||||
<div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
|
||||
<NetworkMap :nodes="mapNodes" :links="mapLinks" />
|
||||
<!-- 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 -->
|
||||
<div v-if="mapActive" class="flex-1 min-h-0">
|
||||
<NetworkMap3D
|
||||
:nodes="mapNodes"
|
||||
:links="mapLinks"
|
||||
:requests="mapRequests"
|
||||
@select="onMapSelect"
|
||||
@approve="approvePending"
|
||||
@reject="rejectPending"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="activeView === 'list'">
|
||||
@@ -243,8 +264,9 @@ import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useSyncStore } from '@/stores/sync'
|
||||
import NetworkMap from '@/components/federation/NetworkMap.vue'
|
||||
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
|
||||
import FederationHeader from './federation/FederationHeader.vue'
|
||||
import DidCardMobile from './federation/DidCardMobile.vue'
|
||||
import RotateDidModal from './federation/RotateDidModal.vue'
|
||||
import QuickActions from './federation/QuickActions.vue'
|
||||
import NodeList from './federation/NodeList.vue'
|
||||
@@ -308,7 +330,22 @@ function setView(id: ViewId) {
|
||||
localStorage.setItem('federation-view', id)
|
||||
}
|
||||
|
||||
const selfDid = ref('')
|
||||
const mapActive = computed(() => activeView.value === 'map' && nodes.value.length > 0)
|
||||
|
||||
/** Map click-through: tapping a peer opens the same detail modal as the list
|
||||
* view. Tapping the self node is a no-op (its actions live in the header). */
|
||||
function onMapSelect(did: string) {
|
||||
const node = nodes.value.find(n => n.did === did)
|
||||
if (node) selectedNode.value = node
|
||||
}
|
||||
|
||||
/** Seeded from the cached DID so the map's centre node (and its links) exist
|
||||
* on the very first frame; the authoritative fetch in onMounted refreshes it
|
||||
* and re-caches. Without this the intro raced the RPC and often played with
|
||||
* no centre. */
|
||||
const selfDid = ref<string>((() => {
|
||||
try { return localStorage.getItem('neode_did') || '' } catch { return '' }
|
||||
})())
|
||||
|
||||
const mapNodes = computed(() => {
|
||||
const result = []
|
||||
@@ -343,6 +380,16 @@ const mapLinks = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
/** Inbound pending requests for the map — blinking yellow nodes the user can
|
||||
* accept/reject in place (same RPCs as the pending panel). */
|
||||
const mapRequests = computed(() => pendingRequests.value
|
||||
.filter(r => !r.outbound && r.state === 'pending')
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
label: r.from_name || `${r.from_nostr_npub.slice(0, 12)}…`,
|
||||
message: r.message,
|
||||
})))
|
||||
|
||||
const dwnStatusRes = useCachedResource<DwnStatus>({
|
||||
key: 'federation.dwn-status',
|
||||
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
|
||||
@@ -778,6 +825,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const result = await rpcClient.getNodeDid()
|
||||
selfDid.value = result.did
|
||||
try { localStorage.setItem('neode_did', result.did) } catch { /* private mode */ }
|
||||
} catch {
|
||||
// Self DID not available
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// live D3 force simulation does not hold for this codebase — a full grep for
|
||||
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
|
||||
// Mesh.vue's component tree; the only D3 force simulation belongs to
|
||||
// NetworkMap.vue (Federation.vue's graph, out of this plan's scope). This
|
||||
// NetworkMap3D.vue (Federation.vue's graph, out of this plan's scope). This
|
||||
// file therefore only covers the six cached fetch groups (Task 1) and the
|
||||
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
|
||||
// D3-specific truths are vacuously satisfied (there is nothing to leak).
|
||||
|
||||
@@ -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>
|
||||
<!-- Mobile: DID below title -->
|
||||
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 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>
|
||||
<!-- Mobile DID card moved to DidCardMobile.vue, rendered by
|
||||
Federation.vue below the view tabs (hidden on the map tab). -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user