306 lines
10 KiB
Svelte
306 lines
10 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import MapView from '$lib/components/MapView.svelte';
|
|
|
|
import { getNearbyMessages } from '$lib/firebase/messages.js';
|
|
import { messagesStore, setMessages } from '$lib/stores/messagesStore.js';
|
|
import { mapStore } from '$lib/stores/mapStore.js';
|
|
import { initAuth } from '$lib/stores/userStore.js';
|
|
import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab
|
|
|
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
|
import SidePanel from '$lib/components/SidePanel.svelte';
|
|
|
|
import ComposeSheet from '$lib/components/ComposeSheet.svelte';
|
|
|
|
let lat = $state();
|
|
let lng = $state();
|
|
let error = $state();
|
|
|
|
let windowWidth = $state(0);
|
|
|
|
let isMobile = $derived(windowWidth < 768);
|
|
|
|
onMount(() => {
|
|
// sign this device in anonymously so messages can be linked to a persistent UID
|
|
initAuth();
|
|
|
|
if (!navigator.geolocation) {
|
|
error = "Your browser doesn't support geolocation :(";
|
|
return; // do nothing
|
|
}
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
lat = position.coords.latitude;
|
|
lng = position.coords.longitude;
|
|
},
|
|
() => {
|
|
error = "Location access denied. Please enable location to use Overheard.";
|
|
}
|
|
);
|
|
// populate the messages store
|
|
navigator.geolocation.getCurrentPosition(
|
|
async (position) => {
|
|
const messages = await getNearbyMessages(position.coords.latitude, position.coords.longitude);
|
|
// setMessages (instead of messagesStore.set) compares against the
|
|
// previous list and plays a soft chime for any newly-appeared pins
|
|
setMessages(messages);
|
|
console.log('messages loaded:', $messagesStore);
|
|
}
|
|
);
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
<svelte:window bind:innerWidth={windowWidth} /> <!--this sends the windowWidth to our mobile checker -->
|
|
|
|
{#if error}
|
|
<p class="error">{error}</p>
|
|
{:else if !(lat && lng)}
|
|
<p class="loading">Looking for you...</p>
|
|
{/if}
|
|
|
|
<!-- map must fill the whole screen; wrapped in .map-container so desktop
|
|
can push it right of the side panel via the media query below.
|
|
(this also removes a duplicate <MapView> that was rendering two map instances)
|
|
on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav -->
|
|
{#if lat && lng}
|
|
<div class="map-container" style="padding-bottom: {windowWidth < 768 ? '64px' : '0'}">
|
|
<MapView {lat} {lng} />
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- show the right panel based on mobile or desktop-->
|
|
{#if windowWidth < 768}
|
|
<BottomSheet message={$mapStore.selectedMessage} />
|
|
{:else}
|
|
<!-- SidePanel itself swaps between the list view and a message detail
|
|
view depending on whether a message is selected -->
|
|
<SidePanel message={$mapStore.selectedMessage} />
|
|
{/if}
|
|
|
|
<!-- pin legend, desktop only.
|
|
icons are inline copies of the exact shapes/viewBoxes from pins.js
|
|
(unreadPin/readPin/echoedPin/locationPin) instead of unrelated emoji,
|
|
so the key visually matches what's on the map. message-pin icons use
|
|
one fixed pastel fill here (rather than pins.js's randomPastel()) since
|
|
a legend needs a single representative swatch, not a random one. -->
|
|
{#if !isMobile}
|
|
<div class="legend">
|
|
<!-- each icon sits in a fixed 36x36 .legend-icon-wrap so the text
|
|
column stays aligned, even though the svgs below are sized
|
|
differently from each other. the star/heart shapes occupy a
|
|
smaller fraction of their own viewBox than the two circles do
|
|
of theirs, so they need a bigger svg size to LOOK the same size
|
|
on screen - sizes below were picked by eye, not by matching
|
|
pixel dimensions -->
|
|
<div class="legend-item">
|
|
<span class="legend-icon-wrap">
|
|
<svg class="legend-icon" viewBox="0 0 32 32" width="23" height="23">
|
|
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
|
|
<circle cx="16" cy="16" r="5" fill="white"/>
|
|
</svg>
|
|
</span>
|
|
<span>You are here</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<span class="legend-icon-wrap">
|
|
<svg class="legend-icon" viewBox="0 0 60 60" width="36" height="36">
|
|
<polygon points="30,12 34,24 47,24 37,32 41,44 30,36 19,44 23,32 13,24 26,24" fill="hsl(265, 60%, 80%)"/>
|
|
</svg>
|
|
</span>
|
|
<span>Unread</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<span class="legend-icon-wrap">
|
|
<svg class="legend-icon" viewBox="0 0 50 50" width="25" height="25">
|
|
<circle cx="25" cy="25" r="20" fill="hsl(265, 60%, 80%)"/>
|
|
</svg>
|
|
</span>
|
|
<span>Read</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<span class="legend-icon-wrap">
|
|
<svg class="legend-icon" viewBox="0 0 56 56" width="30" height="30">
|
|
<path d="M28,46 C28,46 10,33 10,20 C10,13 15,9 21,9 C25,9 28,12 28,17 C28,12 31,9 35,9 C41,9 46,13 46,20 C46,33 28,46 28,46 Z" fill="hsl(265, 60%, 80%)"/>
|
|
</svg>
|
|
</span>
|
|
<span>Echoed</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!--floating action button for adding a message; hidden while a message is open-->
|
|
{#if !$mapStore.composing && !$mapStore.selectedMessage}
|
|
<button
|
|
class="fab"
|
|
onclick={() => mapStore.set(
|
|
{selectedMessage: null, composing: true })}>
|
|
+
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- bottom nav, mobile only -->
|
|
{#if windowWidth < 768}
|
|
<nav class="bottom-nav">
|
|
<a href="/" class="nav-item" class:active={$page.url.pathname === '/'}>
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
|
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
|
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
|
</svg>
|
|
<span>Map</span>
|
|
</a>
|
|
<a href="/archive" class="nav-item" class:active={$page.url.pathname === '/archive'}>
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
|
<path d="M2 9h20"/>
|
|
<path d="M9 4v5"/>
|
|
<path d="M15 4v5"/>
|
|
</svg>
|
|
<span>Archive</span>
|
|
</a>
|
|
</nav>
|
|
{/if}
|
|
|
|
<!--compose sheet (making a message)-->
|
|
{#if $mapStore.composing}
|
|
<!-- pass isMobile so ComposeSheet can render as a bottom sheet on mobile
|
|
(unchanged) vs. a centered popup with backdrop on desktop -->
|
|
<ComposeSheet {lat} {lng} {isMobile} />
|
|
{/if}
|
|
|
|
|
|
<style>
|
|
:global(body) {
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.error, .loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
font-family: Georgia, 'Times New Roman', Times, serif;
|
|
color: #666;
|
|
|
|
}
|
|
|
|
/* purple FAB; only rendered when nothing is composing/selected, so no
|
|
"shifted" state is needed anymore (that rule has been removed) */
|
|
.fab {
|
|
position: fixed;
|
|
bottom: 5rem;
|
|
right: 1.5rem;
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 50%;
|
|
background: #c4a8f5;
|
|
color: white;
|
|
font-size: 1.8rem;
|
|
border: none;
|
|
cursor: pointer;
|
|
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
|
z-index: 150;
|
|
display: flex;
|
|
align-items:center;
|
|
justify-content: center;
|
|
line-height: 1;
|
|
}
|
|
|
|
/* mobile-only bottom nav bar with Map/Archive links */
|
|
.bottom-nav {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 64px;
|
|
background: white;
|
|
border-top: 1px solid #f0f0f0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-around;
|
|
z-index: 200;
|
|
}
|
|
|
|
.nav-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 3px;
|
|
text-decoration: none;
|
|
color: #ccc;
|
|
font-size: 0.7rem;
|
|
font-family: sans-serif;
|
|
transition: color 0.15s;
|
|
}
|
|
|
|
/* highlight the tab matching the current route */
|
|
.nav-item.active {
|
|
color: #c4a8f5;
|
|
}
|
|
|
|
/* map fills the screen on mobile; on desktop it's pushed right of the
|
|
fixed-width SidePanel (340px) so the panel doesn't sit on top of it.
|
|
box-sizing: border-box so the inline padding-bottom (reserved for the
|
|
bottom nav on mobile) shrinks the map instead of overflowing 100vh */
|
|
.map-container {
|
|
width: 100%;
|
|
height: 100vh;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.map-container {
|
|
margin-left: 340px;
|
|
width: calc(100% - 340px);
|
|
}
|
|
}
|
|
|
|
/* floating legend explaining the map pin shapes/colors, desktop only.
|
|
anchored bottom-left, just to the right of the 340px side panel.
|
|
flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
|
.legend {
|
|
position: fixed;
|
|
bottom: 1.5rem;
|
|
left: calc(340px + 4rem);
|
|
background: #f3f0fa;
|
|
border-radius: 14px;
|
|
padding: 0.9rem 1.1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
z-index: 200;
|
|
font-size: 0.85rem;
|
|
color: #444;
|
|
font-family: sans-serif;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
}
|
|
|
|
/* fixed 36x36 box for every icon, centered, so the text after each icon
|
|
lines up in a consistent column regardless of each icon's own width/height
|
|
(which now vary so the shapes LOOK the same size - see template) */
|
|
.legend-icon-wrap {
|
|
width: 36px;
|
|
height: 36px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* flat 2D style for now - drop-shadow filter removed, paper-style shadows may be added later */
|
|
.legend-icon {
|
|
display: block;
|
|
}
|
|
|
|
</style> |