Files
Overheard/src/lib/components/SharePopover.svelte

231 lines
7.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>