Some checks are pending
build-gate / build (push) Waiting to run
Five lanes had 465 insertions across 18 files sitting uncommitted, some for 15 minutes. That is the state that silently lost the shell-scroll fix earlier today: a verified fix existed only in the working tree, a second agent's edit to the same file overwrote it, and the reverted version got committed and described as the fix. Nothing here is finished and no card is being closed on it. This is a snapshot so the work survives an overwrite, taken at a moment when every file had been quiet 15s or more, so no agent was mid-write. Verified coherent before staging rather than committed blind: template-literal, mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags, quoted-scripts and backlog-status all PASS. check:synced-css fails on PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and the same counts as before the swarm started, both untouched by any agent, both already carded. Two untracked files included because both are load-bearing: internal/ check-facet-one-field.mjs is wired into package.json with a self-test, and library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend and PosterDialog -- a shared helper for the modal-scrim fix rather than six copies of the same workaround.
613 lines
22 KiB
Vue
613 lines
22 KiB
Vue
<!--
|
|
ShareButton.vue — Non-technical share modal for the map toolbar.
|
|
|
|
Redesigned to match consumer-grade UX from YouTube, Spotify, Canva, and
|
|
Google Maps. Two tabs:
|
|
|
|
1. "Share Link" — clean URL display + one-click copy + social share icons
|
|
2. "Add to Your Website" — friendly embed guidance with size presets
|
|
|
|
The word "iframe" never appears in any user-visible text. The embed tab
|
|
leads with a plain-English description, then shows the code below.
|
|
|
|
Props:
|
|
isDark — theme flag forwarded from parent
|
|
embedPath — this map's standalone embed page, TREE-RELATIVE
|
|
(`<bundle>/<profile>/index.html`); resolved by utils/embedBase.js
|
|
|
|
Popover closes on:
|
|
- Click outside (shadow-DOM-aware via composedPath)
|
|
- Escape key
|
|
- Re-click the share button
|
|
-->
|
|
<script setup>
|
|
import { ref, computed, inject, unref, provide } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
|
import { resolveShareUrl } from '../../utils/embedBase.js'
|
|
import MapControlButton from './MapControlButton.vue'
|
|
import MapControlMenu from './MapControlMenu.vue'
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Shadow-DOM styles */
|
|
/* ------------------------------------------------------------------ */
|
|
useShadowStyles(`
|
|
/* ---- share button: a thin, slowly turning rainbow ring behind the round button ---- */
|
|
.share-wrap{position:relative;display:inline-block;border-radius:50%;}
|
|
.share-wrap::before{content:'';position:absolute;inset:-2px;border-radius:50%;padding:2px;pointer-events:none;
|
|
background:conic-gradient(#ff6b6b,#ffd166,#6bd67b,#4dabf7,#9775fa,#f783ac,#ff6b6b);
|
|
-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude;
|
|
opacity:0.6;animation:share-ring-turn 5s linear infinite;}
|
|
.share-wrap:hover::before{opacity:0.95;}
|
|
/* The ring itself rotates, so the colours travel around the button in every browser. */
|
|
@keyframes share-ring-turn{to{transform:rotate(360deg);}}
|
|
@media(prefers-reduced-motion:reduce){.share-wrap::before{animation:none;}}
|
|
.share-steps{margin:10px 14px 6px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;}
|
|
.share-steps li{margin:0 0 3px;padding-left:3px;}
|
|
.share-steps li:last-child{font-weight:600;color:#92b195;}
|
|
.share-pop.light .share-steps{color:#3b463d;}
|
|
.share-pop.light .share-steps li:last-child{color:#3b463d;}
|
|
/* ---- popover shell ---- */
|
|
/* ---- popover shell (imitates SemanticTreeLegend .stl-inner) ---- */
|
|
.share-pop{
|
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
font-size:13px;
|
|
color:#e6edf3;
|
|
}
|
|
.share-pop.light{color:#1f2328;}
|
|
|
|
/* ---- tabs ---- */
|
|
.share-tabs{
|
|
display:flex;
|
|
gap:0;
|
|
padding:0 16px;
|
|
margin-top:8px;
|
|
border-bottom:1px solid rgba(255,255,255,0.08);
|
|
}
|
|
.share-pop.light .share-tabs{border-bottom:1px solid rgba(0,0,0,0.08);}
|
|
.share-tab{
|
|
padding:6px 0;
|
|
margin-right:20px;
|
|
font-size:12.5px;
|
|
font-weight:600;
|
|
background:none;border:none;
|
|
color:inherit;opacity:0.5;
|
|
cursor:pointer;
|
|
border-bottom:2px solid transparent;
|
|
transition:opacity 0.15s,border-color 0.2s;
|
|
font-family:inherit;
|
|
white-space:nowrap;
|
|
}
|
|
.share-tab:hover{opacity:0.75;}
|
|
.share-tab.active{
|
|
opacity:1;
|
|
border-bottom-color:#4a90d9;
|
|
}
|
|
.share-pop.light .share-tab.active{
|
|
border-bottom-color:#1a73e8;
|
|
}
|
|
|
|
/* ---- tab body ---- */
|
|
.share-body{
|
|
padding:12px 16px 14px 16px;
|
|
}
|
|
|
|
/* ---- Share Link tab ---- */
|
|
.share-url-box{
|
|
display:flex;
|
|
align-items:center;
|
|
gap:0;
|
|
background:rgba(0,0,0,0.22);
|
|
border:1px solid rgba(255,255,255,0.08);
|
|
border-radius:10px;
|
|
overflow:hidden;
|
|
}
|
|
.share-pop.light .share-url-box{
|
|
background:rgba(0,0,0,0.035);
|
|
border:1px solid rgba(0,0,0,0.10);
|
|
}
|
|
.share-url-text{
|
|
flex:1;
|
|
padding:11px 14px;
|
|
font-size:13px;
|
|
color:#b0b8c8;
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
-webkit-user-select:all;
|
|
user-select:all;
|
|
}
|
|
.share-pop.light .share-url-text{color:#555;}
|
|
|
|
/* ---- primary CTA button ---- */
|
|
.share-primary-btn{
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
gap:8px;
|
|
width:100%;
|
|
padding:12px 0;
|
|
margin-top:14px;
|
|
background:#4a90d9;
|
|
border:none;
|
|
border-radius:10px;
|
|
color:#fff;
|
|
font-size:14.5px;
|
|
font-weight:600;
|
|
cursor:pointer;
|
|
transition:background 0.15s,transform 0.1s;
|
|
font-family:inherit;
|
|
}
|
|
.share-primary-btn:hover{background:#3d7ec5;}
|
|
.share-primary-btn:active{transform:scale(0.98);}
|
|
.share-primary-btn.copied{
|
|
background:#2ea44f;
|
|
}
|
|
.share-pop.light .share-primary-btn{
|
|
background:#1a73e8;
|
|
}
|
|
.share-pop.light .share-primary-btn:hover{
|
|
background:#1565c0;
|
|
}
|
|
.share-pop.light .share-primary-btn.copied{
|
|
background:#2ea44f;
|
|
}
|
|
|
|
/* ---- social row ---- */
|
|
.share-social-row{
|
|
display:flex;
|
|
align-items:center;
|
|
gap:10px;
|
|
margin-top:16px;
|
|
padding-top:14px;
|
|
border-top:1px solid rgba(255,255,255,0.06);
|
|
}
|
|
.share-pop.light .share-social-row{
|
|
border-top:1px solid rgba(0,0,0,0.06);
|
|
}
|
|
.share-social-label{
|
|
font-size:12px;
|
|
opacity:0.45;
|
|
font-weight:500;
|
|
margin-right:auto;
|
|
white-space:nowrap;
|
|
}
|
|
.share-social-btn{
|
|
width:36px;height:36px;
|
|
border-radius:50%;
|
|
border:1px solid rgba(255,255,255,0.10);
|
|
background:rgba(255,255,255,0.06);
|
|
display:flex;align-items:center;justify-content:center;
|
|
cursor:pointer;
|
|
color:inherit;
|
|
transition:background 0.15s,border-color 0.15s;
|
|
}
|
|
.share-social-btn:hover{
|
|
background:rgba(255,255,255,0.12);
|
|
border-color:rgba(255,255,255,0.18);
|
|
}
|
|
.share-pop.light .share-social-btn{
|
|
border:1px solid rgba(0,0,0,0.10);
|
|
background:rgba(0,0,0,0.03);
|
|
}
|
|
.share-pop.light .share-social-btn:hover{
|
|
background:rgba(0,0,0,0.07);
|
|
border-color:rgba(0,0,0,0.16);
|
|
}
|
|
|
|
/* ---- Embed tab ---- */
|
|
.share-embed-desc{
|
|
font-size:13.5px;
|
|
line-height:1.55;
|
|
opacity:0.72;
|
|
margin-bottom:16px;
|
|
}
|
|
|
|
.share-size-pills{
|
|
display:flex;
|
|
gap:8px;
|
|
margin-bottom:14px;
|
|
}
|
|
.share-size-pill{
|
|
padding:6px 14px;
|
|
border-radius:20px;
|
|
border:1px solid rgba(255,255,255,0.12);
|
|
background:transparent;
|
|
color:inherit;
|
|
font-size:12.5px;
|
|
font-weight:500;
|
|
cursor:pointer;
|
|
transition:background 0.15s,border-color 0.15s;
|
|
font-family:inherit;
|
|
}
|
|
.share-size-pill:hover{
|
|
background:rgba(255,255,255,0.06);
|
|
}
|
|
.share-size-pill.active{
|
|
background:rgba(74,144,217,0.18);
|
|
border-color:rgba(74,144,217,0.45);
|
|
color:#7ab8f5;
|
|
}
|
|
.share-pop.light .share-size-pill{
|
|
border:1px solid rgba(0,0,0,0.12);
|
|
}
|
|
.share-pop.light .share-size-pill:hover{
|
|
background:rgba(0,0,0,0.04);
|
|
}
|
|
.share-pop.light .share-size-pill.active{
|
|
background:rgba(26,115,232,0.10);
|
|
border-color:rgba(26,115,232,0.40);
|
|
color:#1a73e8;
|
|
}
|
|
|
|
.share-code-box{
|
|
background:rgba(0,0,0,0.28);
|
|
border:1px solid rgba(255,255,255,0.06);
|
|
border-radius:10px;
|
|
padding:12px 14px;
|
|
font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;
|
|
font-size:11.5px;
|
|
line-height:1.5;
|
|
color:#a0aab8;
|
|
word-break:break-all;
|
|
white-space:pre-wrap;
|
|
max-height:90px;
|
|
overflow-y:auto;
|
|
-webkit-user-select:all;
|
|
user-select:all;
|
|
}
|
|
.share-pop.light .share-code-box{
|
|
background:rgba(0,0,0,0.035);
|
|
border:1px solid rgba(0,0,0,0.08);
|
|
color:#444;
|
|
}
|
|
|
|
/* ---- copy code button (secondary style) ---- */
|
|
.share-secondary-btn{
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
gap:8px;
|
|
width:100%;
|
|
padding:11px 0;
|
|
margin-top:12px;
|
|
background:rgba(255,255,255,0.08);
|
|
border:1px solid rgba(255,255,255,0.12);
|
|
border-radius:10px;
|
|
color:#e0e4ec;
|
|
font-size:14px;
|
|
font-weight:600;
|
|
cursor:pointer;
|
|
transition:background 0.15s,border-color 0.15s,transform 0.1s;
|
|
font-family:inherit;
|
|
}
|
|
.share-secondary-btn:hover{
|
|
background:rgba(255,255,255,0.13);
|
|
border-color:rgba(255,255,255,0.18);
|
|
}
|
|
.share-secondary-btn:active{transform:scale(0.98);}
|
|
.share-secondary-btn.copied{
|
|
background:rgba(46,164,79,0.16);
|
|
border-color:rgba(46,164,79,0.35);
|
|
color:#5cd680;
|
|
}
|
|
.share-pop.light .share-secondary-btn{
|
|
background:rgba(0,0,0,0.04);
|
|
border:1px solid rgba(0,0,0,0.12);
|
|
color:#333;
|
|
}
|
|
.share-pop.light .share-secondary-btn:hover{
|
|
background:rgba(0,0,0,0.07);
|
|
}
|
|
.share-pop.light .share-secondary-btn.copied{
|
|
background:rgba(46,164,79,0.10);
|
|
border-color:rgba(46,164,79,0.30);
|
|
color:#2ea44f;
|
|
}
|
|
|
|
/* ---- responsive: align with MapToolbar's layout convention ----
|
|
The research-map profile uses @media(max-width:767px) and positions the
|
|
search bar at top:10, left:10, right:10. The geocoder pill renders ~60px
|
|
tall on mobile, so its bottom edge is ~70px. MapToolbar sits at top:80
|
|
(10px gap below search). We mirror MapToolbar exactly so the popover's
|
|
top edge aligns with the top of the '+' button.
|
|
right:52 = MapToolbar right(10) + button-width(36) + 6px breathing room. */
|
|
`, 'share-button')
|
|
|
|
const { t, tm, rt } = useI18n()
|
|
|
|
const props = defineProps({
|
|
isDark: { type: Boolean, default: false },
|
|
embedPath: { type: String, default: '' }
|
|
})
|
|
|
|
/* ---- state ---- */
|
|
const rootEl = ref(null)
|
|
const isOpen = ref(false)
|
|
const popoverEl = ref(null)
|
|
const btnEl = ref(null)
|
|
const activeTab = ref('link') // 'link' | 'embed'
|
|
const linkCopied = ref(false)
|
|
const embedCopied = ref(false)
|
|
const embedSize = ref('medium') // 'small' | 'medium' | 'large'
|
|
|
|
const sizePresets = {
|
|
small: { label: 'Small', height: 400 },
|
|
medium: { label: 'Medium', height: 600 },
|
|
large: { label: 'Large', height: 800 }
|
|
}
|
|
|
|
/* ---- computed ---- */
|
|
/* The standalone embed page this map is shared/iframed as — resolved by
|
|
utils/embedBase.js (its header documents the full ladder). In short:
|
|
(1) profileConfig.shareUrl — a WHOLE url the HOST supplies wins (the dev
|
|
plugin gallery passes its own #/<bundle>/<profile>[/<instance>] deep link);
|
|
(2) the `embedPath` prop — the PROFILE owns an ID→URL table and passes the
|
|
active instance's page (parameterized profiles keyed by active tab id) —
|
|
or the app-profile config (provided by ProfileLoader as 'profileConfig')
|
|
via profile-config.shareEmbedPath. Paths are TREE-RELATIVE
|
|
(`<bundle>/<profile>/index.html`) and join the embed base:
|
|
profileConfig.embedBaseUrl → window.MAP_EMBED_BASE_URL → the root the
|
|
built bundle derives from its OWN script url — so a dropped tree shares
|
|
working links on the plugin gallery, doxa.life or any CDN with no config.
|
|
Absolute urls pass through; a leading '/' is legacy root-relative;
|
|
(3) the page's own location — each map builds to a self-contained page
|
|
("Share shares THE MAP"), so with nothing configured the current page IS
|
|
the correct embed url. */
|
|
const profileConfig = inject('profileConfig', null)
|
|
const shareUrl = computed(() => resolveShareUrl({
|
|
embedPath: props.embedPath,
|
|
profileConfig: unref(profileConfig) || null,
|
|
}))
|
|
|
|
const embedSnippet = computed(() => {
|
|
const h = sizePresets[embedSize.value].height
|
|
return '<iframe src="' + shareUrl.value + '" width="100%" height="' + h + '" style="border:none; border-radius:12px;" allowfullscreen></iframe>'
|
|
})
|
|
|
|
/* ---- actions ---- */
|
|
function toggle() {
|
|
isOpen.value = !isOpen.value
|
|
if (isOpen.value) {
|
|
activeTab.value = 'link'
|
|
linkCopied.value = false
|
|
embedCopied.value = false
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
}
|
|
|
|
/* Copy that works EVERYWHERE (maintainer bug 2026-07-11: on file:// the button did nothing —
|
|
navigator.clipboard exists only in secure contexts (https/localhost). Fallback: the
|
|
classic hidden-textarea + execCommand path, which insecure contexts still support. */
|
|
async function copyText(text) {
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text)
|
|
return true
|
|
}
|
|
} catch { /* fall through to the legacy path */ }
|
|
try {
|
|
const ta = document.createElement('textarea')
|
|
ta.value = text
|
|
ta.setAttribute('readonly', '')
|
|
ta.style.cssText = 'position:fixed;top:-1000px;opacity:0'
|
|
document.body.appendChild(ta)
|
|
ta.select()
|
|
const ok = document.execCommand('copy')
|
|
document.body.removeChild(ta)
|
|
return ok
|
|
} catch { return false }
|
|
}
|
|
|
|
async function copyLink() {
|
|
if (await copyText(shareUrl.value)) {
|
|
linkCopied.value = true
|
|
setTimeout(() => { linkCopied.value = false }, 2000)
|
|
}
|
|
}
|
|
|
|
async function copyEmbed() {
|
|
if (await copyText(embedSnippet.value)) {
|
|
embedCopied.value = true
|
|
setTimeout(() => { embedCopied.value = false }, 2000)
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Anchoring OFF a map — the "menu opens off the right edge" fix */
|
|
/* ------------------------------------------------------------------ */
|
|
/*
|
|
★ DRIVER-REPORTED TWICE (2026-09-22): "when you click the share button on the research
|
|
dashboard, the menu is showing down to the RIGHT of the share button, and the share button is at
|
|
the very top right of the page. So down-to-the-right is actually OFF the page."
|
|
|
|
THE ROOT CAUSE IS AN ABSENT ANCHOR, NOT A WRONG ALIGNMENT CHOICE — measured, and it is a
|
|
DIFFERENT bug from the FilterDropdown one it superficially resembles. That one was a
|
|
position:fixed popover whose viewport frame was stolen by `contain:layout paint` on an ancestor.
|
|
This menu is not fixed at all. MapControlMenu.place() reads an INJECTED `mapToolbar`:
|
|
|
|
const tb = toolbar?.el?.value, area = host.value
|
|
if (!tb || !area) { style.value = {}; return } // <-- this branch
|
|
|
|
`provide('mapToolbar')` comes from MapToolbar.vue, and in the research dashboard MapToolbar is
|
|
rendered by DashboardMapPanel — a SIBLING of the top bar, not an ancestor of it. inject() walks
|
|
the COMPONENT TREE, so a ShareButton mounted in the top bar resolves the fallback `null`, place()
|
|
returns an EMPTY style, and `.mcm{position:absolute}` with no top/right falls back to its STATIC
|
|
position: immediately below-and-right of a button already at the far top-right. Off the page,
|
|
exactly as reported. Nothing was ever choosing "right" — nothing was choosing anything.
|
|
|
|
SO THE FIX IS TO GIVE IT AN ANCHOR, not to add a second placement scheme. When no toolbar is
|
|
provided, this button provides one pointing at its OWN wrapper, and marks that wrapper as the
|
|
containing area. MapControlMenu's existing measured logic then does the whole job unchanged:
|
|
it compares the anchor's centre to the area's centre, opens on the INNER side, and clamps the
|
|
width to what fits. A share button at the top right is right-of-centre, so the menu opens to the
|
|
LEFT — which is what the Driver asked for, arrived at by measurement rather than by hardcoding a
|
|
side that would then be wrong for a left-hand placement.
|
|
|
|
WHY `inject` FIRST AND `provide` ONLY AS A FALLBACK: on a real map this component must keep
|
|
behaving byte-identically — the toolbar is the correct anchor there, and the menu should still
|
|
align to the control COLUMN, not to one button in it. This only engages where the injection
|
|
genuinely resolves to nothing, so no map profile changes.
|
|
*/
|
|
const providedToolbar = inject('mapToolbar', null)
|
|
const selfAnchor = ref(null)
|
|
// Re-provide UNCONDITIONALLY so the value is stable for the child's inject(); `el` stays null when
|
|
// a real toolbar was found, and MapControlMenu keeps using that toolbar instead.
|
|
provide('mapToolbar', providedToolbar || { el: selfAnchor })
|
|
const hasOwnAnchor = !providedToolbar
|
|
|
|
/*
|
|
★ THIS IS A FUNCTION REF, AND onMounted WOULD NOT HAVE WORKED — the hazard is worth naming,
|
|
because the fix silently does NOTHING if it is written the obvious way.
|
|
|
|
MapControlMenu latches its area ONCE, in its own onMounted:
|
|
host.value = toolbar?.el?.value?.closest(AREA) || null
|
|
and place() bails forever after if that is null. MapControlMenu is a CHILD of this component, and
|
|
Vue runs a child's mounted hook BEFORE its parent's. So an onMounted here would set the anchor
|
|
one tick too late: the child would have already read `null`, latched `host = null`, and no later
|
|
assignment would revive it — the menu would go on opening at its static position and the fix
|
|
would look applied while changing nothing.
|
|
|
|
A function ref is invoked during RENDER, before any mounted hook on either component, so the
|
|
anchor and the area tag are in place by the time the child looks for them.
|
|
|
|
`data-map-area` is what MapControlMenu.AREA finds via closest(). It is tagged on the OFFSET
|
|
PARENT, not on .share-wrap: the wrapper is a 32px button, and an area that small would clamp the
|
|
menu to its 200px minimum jammed against the edge. The offset parent is the top bar, which spans
|
|
the row, so the menu has the full width to open leftward into.
|
|
*/
|
|
function bindAnchor(el) {
|
|
rootEl.value = el || null
|
|
if (!hasOwnAnchor || !el) { if (!el) selfAnchor.value = null; return }
|
|
const area = el.offsetParent || el.parentElement
|
|
// Never re-tag a host that already declares itself an area — that host owns the decision.
|
|
if (area && !area.closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]')) {
|
|
area.setAttribute('data-map-area', 'share-fallback')
|
|
}
|
|
selfAnchor.value = el
|
|
}
|
|
|
|
/* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every
|
|
control menu); this button only toggles `isOpen`. */
|
|
</script>
|
|
|
|
<template>
|
|
<!-- :ref is a FUNCTION, deliberately — it must run during render, before MapControlMenu's
|
|
mounted hook latches its area. See bindAnchor() in <script> for why onMounted is too late. -->
|
|
<div :ref="bindAnchor" class="share-wrap" data-share-root>
|
|
<MapControlButton
|
|
ref="btnEl"
|
|
:is-dark="isDark"
|
|
:active="isOpen"
|
|
:title="t('buttons.share')"
|
|
@click="toggle"
|
|
>
|
|
<!-- Share icon: box with upward arrow (iOS / Material standard) -->
|
|
<svg xmlns="http://www.w3.org/2000/svg"
|
|
width="18" height="18" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7"/>
|
|
<polyline points="16 6 12 2 8 6"/>
|
|
<line x1="12" y1="2" x2="12" y2="15"/>
|
|
</svg>
|
|
</MapControlButton>
|
|
|
|
<!-- =================== Menu (shared MapControlMenu owns placement + chrome) =================== -->
|
|
<MapControlMenu :open="isOpen" :title="t('buttons.share')" :is-dark="isDark" @close="close">
|
|
<div ref="popoverEl" class="share-pop" :class="{ light: !isDark }">
|
|
<!-- The multiplication path — one line each, top of the menu -->
|
|
<ul class="share-steps">
|
|
<li v-for="(step, i) in tm('buttons.shareSteps')" :key="i">{{ rt(step) }}</li>
|
|
</ul>
|
|
<!-- Tabs -->
|
|
<div class="share-tabs">
|
|
<button
|
|
class="share-tab"
|
|
:class="{ active: activeTab === 'link' }"
|
|
@click="activeTab = 'link'"
|
|
>Embed link</button>
|
|
<button
|
|
class="share-tab"
|
|
:class="{ active: activeTab === 'embed' }"
|
|
@click="activeTab = 'embed'"
|
|
>Add to Your Website</button>
|
|
</div>
|
|
|
|
<!-- ===== Tab 1: Share Link ===== -->
|
|
<div v-if="activeTab === 'link'" class="share-body">
|
|
<!-- URL display -->
|
|
<div class="share-url-box">
|
|
<div class="share-url-text">{{ shareUrl }}</div>
|
|
</div>
|
|
|
|
<!-- Copy Link primary CTA -->
|
|
<button
|
|
class="share-primary-btn"
|
|
:class="{ copied: linkCopied }"
|
|
@click="copyLink"
|
|
>
|
|
<!-- Copy / Check icon -->
|
|
<svg v-if="!linkCopied" width="16" height="16" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
</svg>
|
|
<svg v-else width="16" height="16" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2.5"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
{{ linkCopied ? 'Copied!' : 'Copy Link' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ===== Tab 2: Add to Your Website ===== -->
|
|
<div v-if="activeTab === 'embed'" class="share-body">
|
|
<!-- Friendly description -->
|
|
<div class="share-embed-desc">
|
|
Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets).
|
|
</div>
|
|
|
|
<!-- Size presets -->
|
|
<div class="share-size-pills">
|
|
<button
|
|
v-for="(preset, key) in sizePresets"
|
|
:key="key"
|
|
class="share-size-pill"
|
|
:class="{ active: embedSize === key }"
|
|
@click="embedSize = key"
|
|
>{{ preset.label }}</button>
|
|
</div>
|
|
|
|
<!-- Code box -->
|
|
<div class="share-code-box">{{ embedSnippet }}</div>
|
|
|
|
<!-- Copy Code button -->
|
|
<button
|
|
class="share-secondary-btn"
|
|
:class="{ copied: embedCopied }"
|
|
@click="copyEmbed"
|
|
>
|
|
<svg v-if="!embedCopied" width="15" height="15" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
</svg>
|
|
<svg v-else width="15" height="15" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2.5"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
{{ embedCopied ? 'Copied!' : 'Copy Code' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</MapControlMenu>
|
|
</div>
|
|
</template>
|