231 lines
7.8 KiB
Svelte
231 lines
7.8 KiB
Svelte
<script>
|
||
// Share button + popover for the message detail view (BottomSheet on
|
||
// mobile, SidePanel on desktop) - shared between both so the link/QR
|
||
// logic and styling only live in one place.
|
||
import QRCode from 'qrcode';
|
||
|
||
// --- portal action --------------------------------------------------------
|
||
// On mobile, this component is rendered inside BottomSheet's `.sheet`,
|
||
// which has a `transform` (its slide-up/down animation). A `transform` on
|
||
// an ancestor creates its own containing block for `position: fixed`
|
||
// descendants, so the popover's `top: 50%`/`left: 50%` were being measured
|
||
// against `.sheet`'s box (anchored to the bottom of the screen) instead of
|
||
// the actual viewport - centering it too low and cutting it off at the
|
||
// bottom. Moving the popover/backdrop to a direct child of <body> (which
|
||
// has no transform) restores normal viewport-relative `position: fixed`
|
||
// centering on both mobile and desktop.
|
||
function portal(node) {
|
||
document.body.appendChild(node);
|
||
return {
|
||
destroy() {
|
||
node.remove();
|
||
}
|
||
};
|
||
}
|
||
|
||
let { message } = $props();
|
||
|
||
// popover open/closed, the generated QR code image, and "copied" feedback
|
||
let open = $state(false);
|
||
let qrDataUrl = $state(null);
|
||
let copied = $state(false);
|
||
|
||
// --- share URL ----------------------------------------------------------
|
||
// `?message=<id>` on the root route, e.g. https://yourapp.web.app/?message=abc123.
|
||
// A query param on `/` (rather than a new /message/[id] route) was chosen
|
||
// because `/` already does everything opening a shared message needs - it
|
||
// loads the map, gets the user's location, etc. +page.svelte's auto-open
|
||
// logic just reads this param once the map has loaded (see its onMount/
|
||
// $effect) and sets mapStore.selectedMessage, then clears the param.
|
||
// window.location.origin is only available in the browser, but this is
|
||
// only ever read after a click (openShare below), so it's never evaluated
|
||
// during SSR.
|
||
let shareUrl = $derived(
|
||
message ? `${window.location.origin}/?message=${message.id}` : ''
|
||
);
|
||
|
||
// Generates the QR code as soon as the popover opens, rather than eagerly
|
||
// for every message (most messages are never shared, so there's no point
|
||
// paying the encoding cost up front).
|
||
// QRCode.toDataURL renders the code as a PNG and returns it as a base64
|
||
// data: URL - the simplest way to get it into an <img src>, with no
|
||
// <canvas> element or ref to manage. `qrcode` is a small, dependency-free
|
||
// client-side library, so this works fully offline and never calls out to
|
||
// a third-party QR-generation API.
|
||
async function openShare() {
|
||
open = true;
|
||
copied = false;
|
||
qrDataUrl = await QRCode.toDataURL(shareUrl, { width: 180, margin: 1 });
|
||
}
|
||
|
||
function closeShare() {
|
||
open = false;
|
||
}
|
||
|
||
async function copyLink() {
|
||
await navigator.clipboard.writeText(shareUrl);
|
||
copied = true;
|
||
setTimeout(() => { copied = false; }, 2000);
|
||
}
|
||
</script>
|
||
|
||
<!-- icon-only button, sized to sit alongside Echo/Let go in .actions -->
|
||
<button
|
||
type="button"
|
||
class="share-button"
|
||
onclick={openShare}
|
||
aria-label="Share this message"
|
||
title="Share this message"
|
||
>
|
||
<!-- "export"-style share icon: arrow up out of a tray -->
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M12 16V4" />
|
||
<path d="M7 8 L12 3 L17 8" />
|
||
<path d="M5 12v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6" />
|
||
</svg>
|
||
</button>
|
||
|
||
{#if open}
|
||
<!-- use:portal moves this whole group to a direct child of <body>, so the
|
||
fixed-position backdrop/popover below center on the real viewport
|
||
instead of BottomSheet's transformed `.sheet` (see the portal action
|
||
above) -->
|
||
<div use:portal>
|
||
<!-- full-screen button (not a div) so it's keyboard-focusable, same
|
||
click-outside-to-close pattern as ComposeSheet's .backdrop -->
|
||
<button class="share-backdrop" onclick={closeShare} aria-label="Close share popover"></button>
|
||
|
||
<div class="share-popover">
|
||
<button class="share-close" onclick={closeShare} aria-label="Close">×</button>
|
||
|
||
<p class="share-title">Share this message</p>
|
||
|
||
{#if qrDataUrl}
|
||
<img class="share-qr" src={qrDataUrl} alt="QR code linking to this message" />
|
||
{/if}
|
||
|
||
<div class="share-link-row">
|
||
<!-- readonly input rather than plain text so the URL is easy to
|
||
select/copy manually too, as a fallback to the button below -->
|
||
<input
|
||
class="share-link-input"
|
||
type="text"
|
||
readonly
|
||
value={shareUrl}
|
||
onclick={(e) => e.target.select()}
|
||
/>
|
||
<button class="share-copy" onclick={copyLink}>
|
||
{copied ? 'Copied!' : 'Copy link'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
/* small square icon button, matching the flat 2D style of
|
||
.echo-button/.letgo-button but not flex: 1 - it's a secondary action
|
||
alongside the two main ones */
|
||
.share-button {
|
||
flex: 0 0 auto;
|
||
width: 44px;
|
||
padding: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
color: #111;
|
||
border: 1.5px solid #ddd;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* dimmed backdrop behind the popover, click to close - same approach as
|
||
ComposeSheet's .backdrop, raised above it (z-index 350) since the
|
||
popover can be opened from inside the compose-less message view but
|
||
should still sit above any other overlay */
|
||
.share-backdrop {
|
||
border: none;
|
||
cursor: default;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
z-index: 400;
|
||
}
|
||
|
||
/* small centered card, styled consistently with the app's pastel/
|
||
parchment palette (white card, soft rounded corners, muted text) */
|
||
.share-popover {
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 401;
|
||
background: #fdfaf5;
|
||
border-radius: 16px;
|
||
padding: 1.25rem;
|
||
width: 260px;
|
||
max-width: 90vw;
|
||
text-align: center;
|
||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||
}
|
||
|
||
.share-close {
|
||
position: absolute;
|
||
top: 0.5rem;
|
||
right: 0.75rem;
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.2rem;
|
||
line-height: 1;
|
||
color: #bbb;
|
||
cursor: pointer;
|
||
padding: 0.25rem;
|
||
}
|
||
|
||
.share-title {
|
||
margin: 0 0 0.9rem;
|
||
font-size: 0.95rem;
|
||
color: #555;
|
||
}
|
||
|
||
/* QR code rendered at a modest, readable size - not the full card width */
|
||
.share-qr {
|
||
width: 160px;
|
||
height: 160px;
|
||
border-radius: 8px;
|
||
margin: 0 auto 0.9rem;
|
||
display: block;
|
||
}
|
||
|
||
.share-link-row {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.share-link-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-family: sans-serif;
|
||
font-size: 0.75rem;
|
||
color: #888;
|
||
background: #f5f1ea;
|
||
border: 1px solid #eee;
|
||
border-radius: 8px;
|
||
padding: 0.4rem 0.5rem;
|
||
}
|
||
|
||
.share-copy {
|
||
flex-shrink: 0;
|
||
font-family: sans-serif;
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
background: #c4a8f5;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 0.4rem 0.6rem;
|
||
cursor: pointer;
|
||
}
|
||
</style>
|