forked from 20266142/Overheard
leave a message functionallity (sheet and fab)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { collection, query, where, getDocs } from 'firebase/firestore'; // tools for building and running db queries
|
import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; // tools for building and running db queries
|
||||||
import { db } from './config'; // database connection
|
import { db } from './config'; // database connection
|
||||||
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
|
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
|
||||||
|
import { doc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
|
||||||
|
import ngeohash from 'ngeohash';
|
||||||
|
|
||||||
export async function getNearbyMessages(lat, lng) {
|
export async function getNearbyMessages(lat, lng) {
|
||||||
const prefix = getQueryPrefix(lat, lng);
|
const prefix = getQueryPrefix(lat, lng);
|
||||||
@@ -13,4 +15,37 @@ export async function getNearbyMessages(lat, lng) {
|
|||||||
|
|
||||||
const snapshot = await getDocs(q);
|
const snapshot = await getDocs(q);
|
||||||
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function echoMessage(messageId) {
|
||||||
|
const ref = doc(db, 'messages', messageId);
|
||||||
|
await updateDoc(ref, {
|
||||||
|
echoCount: increment(1),
|
||||||
|
lastEchoAt: serverTimestamp()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMessage(lat, lng, text, imageUrl = ''){
|
||||||
|
const geohash = ngeohash.encode(lat, lng, 6);
|
||||||
|
|
||||||
|
await addDoc(collection(db, 'messages'), {
|
||||||
|
text,
|
||||||
|
imageUrl,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
geohash,
|
||||||
|
createdAt: serverTimestamp(),
|
||||||
|
lastEchoAt: serverTimestamp(),
|
||||||
|
echoCount: 0,
|
||||||
|
sessionId: getSessionId()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionId() {
|
||||||
|
let id = localStorage.getItem('overheard_session');
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID(); // created random useer id
|
||||||
|
localStorage.setItem('oveheard_session', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapStore } from '$lib/stores/mapStore.js';
|
import { mapStore } from '$lib/stores/mapStore.js';
|
||||||
|
import { getDecayInfo } from '$lib/utils/time.js'
|
||||||
|
import { echoMessage } from '$lib/firebase/messages.js'
|
||||||
|
|
||||||
let { message } = $props();
|
let { message } = $props();
|
||||||
|
|
||||||
|
let decay = $derived(
|
||||||
|
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
let echoed = $state(false);
|
||||||
|
|
||||||
|
async function handleEcho() {
|
||||||
|
await echoMessage(message.id);
|
||||||
|
echoed = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- if message exists, sheet is visible -->
|
<!-- if message exists, sheet is visible -->
|
||||||
@@ -10,11 +23,16 @@
|
|||||||
<div class="handle"> </div>
|
<div class="handle"> </div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p class="message-text">{message.text}</p>
|
<p class="message-text">{message.text}</p>
|
||||||
<p class="meta">
|
{#if decay}
|
||||||
left {message.lat.toFixed(4)}, {message.lng.toFixed(4)}
|
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||||
</p>
|
{/if}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="echo-button">Echo</button>
|
<button class="echo-button"
|
||||||
|
class:echoed={echoed}
|
||||||
|
onclick={handleEcho}
|
||||||
|
disabled={echoed}>
|
||||||
|
{echoed ? 'Echoed' : 'Echo'}
|
||||||
|
</button>
|
||||||
<button class="letgo-button" onclick={() => mapStore.set(
|
<button class="letgo-button" onclick={() => mapStore.set(
|
||||||
{ selectedMessage: null, composing: false })}>
|
{ selectedMessage: null, composing: false })}>
|
||||||
let go
|
let go
|
||||||
@@ -90,5 +108,14 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.echo-button.echoed {
|
||||||
|
background: #4ecdc4;
|
||||||
|
animation: pulsate 1.5s ease-in-out 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulsate {
|
||||||
|
0%, 100% {box-shadow: 0 0 0 0 rgba(78,205,196,0.4); }
|
||||||
|
50% {box-shadow: 0 0 0 12px rgba(78, 205, 196, 0);}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
138
src/lib/components/ComposeSheet.svelte
Normal file
138
src/lib/components/ComposeSheet.svelte
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script>
|
||||||
|
import { mapStore } from '$lib/stores/mapStore.js';
|
||||||
|
import { messagesStore } from '$lib/stores/messagesStore.js';
|
||||||
|
import { addMessage } from '$lib/firebase/messages.js'
|
||||||
|
|
||||||
|
let {lat, lng} = $props();
|
||||||
|
|
||||||
|
let text = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let remaining = $derived(240-text.length);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!text.trim() || remaining < 0) return;
|
||||||
|
submitting = true;
|
||||||
|
|
||||||
|
await addMessage(lat, lng, text.trim());
|
||||||
|
|
||||||
|
//refresh the messages store so pin is there
|
||||||
|
const { getNearbyMessages } = await import('$lib/firebase/messages.js');
|
||||||
|
const updated = await getNearbyMessages(lat, lng);
|
||||||
|
messagesStore.set(updated);
|
||||||
|
|
||||||
|
//reset and close
|
||||||
|
text = '';
|
||||||
|
submitting = false;
|
||||||
|
mapStore.set({selectedMessage: null, composing: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="compose" class:visible={true}>
|
||||||
|
<div class="compose-header">
|
||||||
|
<button class="cancel" onclick={() => mapStore.set(
|
||||||
|
{selectedMessage: null, composing: false}
|
||||||
|
)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span class="title">Leave a message</span>
|
||||||
|
<span class="counter" class:over={remaining < 0}>{remaining}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
bind:value={text}
|
||||||
|
placeholder="Share a moment for someone else to overhear"
|
||||||
|
rows="5"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="submit"
|
||||||
|
onclick={handleSubmit}
|
||||||
|
disabled={!text.trim() || remaining < 0 || submitting}>
|
||||||
|
{submitting ? "leaving it here..." : "leave it here"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.compose {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
padding: 1.2rem 1.5rem 2.5rem;
|
||||||
|
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #999;
|
||||||
|
font-variant-numeric: tublar-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter.over {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1.5px solid #eee;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
border-color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
background: #111;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapStore } from '$lib/stores/mapStore.js';
|
import { mapStore } from '$lib/stores/mapStore.js';
|
||||||
|
import { getDecayInfo } from '$lib/utils/time.js';
|
||||||
|
import { echoMessage } from '$lib/firebase/messages.js'
|
||||||
|
|
||||||
let { message } = $props();
|
let { message } = $props();
|
||||||
|
|
||||||
|
let decay = $derived(
|
||||||
|
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
let echoed = $state(false);
|
||||||
|
|
||||||
|
async function handleEcho() {
|
||||||
|
await echoMessage(message.id);
|
||||||
|
echoed = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class = "panel">
|
<div class = "panel">
|
||||||
{#if message}
|
{#if message}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p class="message-text">{message.text}</p>
|
<p class="message-text">{message.text}</p>
|
||||||
<p class="meta">
|
{#if decay}
|
||||||
left {message.lat.toFixed(4)}, {message.lng.toFixed(4)}
|
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||||
</p>
|
{/if}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="echo-button">Echo</button>
|
<button class="echo-button"
|
||||||
|
class:echoed={echoed}
|
||||||
|
onclick={handleEcho}
|
||||||
|
disabled={echoed}>
|
||||||
|
{echoed ? 'Echoed' : 'Echo'}
|
||||||
|
</button>
|
||||||
<button class="letgo-button" onclick={() => mapStore.set(
|
<button class="letgo-button" onclick={() => mapStore.set(
|
||||||
{selectedMessage: null, composing: false})}>
|
{selectedMessage: null, composing: false})}>
|
||||||
Let go
|
Let go
|
||||||
@@ -87,6 +105,15 @@
|
|||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.echo-button.echoed {
|
||||||
|
background: #4ecdc4;
|
||||||
|
animation: pulsate 1.5s ease-in-out 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulsate {
|
||||||
|
0%, 100% {box-shadow: 0 0 0 0 rgba(78,205,196,0.4); }
|
||||||
|
50% {box-shadow: 0 0 0 12px rgba(78, 205, 196, 0);}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
15
src/lib/utils/time.js
Normal file
15
src/lib/utils/time.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function getDecayInfo(createdAt, lastEchoAt) {
|
||||||
|
const now = Date.now();
|
||||||
|
const echoTime = lastEchoAt?.toMillis() ?? createdAt.toMillis();
|
||||||
|
const createdTime = createdAt.toMillis();
|
||||||
|
|
||||||
|
const daysSinceCreated = Math.floor((now - createdTime) / (1000 * 60 * 60 * 24));
|
||||||
|
const daysSinceEcho = Math.floor((now - echoTime)/(1000 * 60 * 60 * 24));
|
||||||
|
const daysLeft = 30 - daysSinceEcho;
|
||||||
|
|
||||||
|
return {
|
||||||
|
daysAgo: daysSinceCreated,
|
||||||
|
daysLeft: Math.max(0, daysLeft),
|
||||||
|
isExpired: daysLeft <= 0
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||||
import SidePanel from '$lib/components/SidePanel.svelte';
|
import SidePanel from '$lib/components/SidePanel.svelte';
|
||||||
|
|
||||||
|
import ComposeSheet from '$lib/components/ComposeSheet.svelte';
|
||||||
|
|
||||||
let lat = $state();
|
let lat = $state();
|
||||||
let lng = $state();
|
let lng = $state();
|
||||||
let error = $state();
|
let error = $state();
|
||||||
@@ -66,6 +68,22 @@
|
|||||||
<SidePanel message={$mapStore.selectedMessage} />
|
<SidePanel message={$mapStore.selectedMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!--floating action button for adding a message-->
|
||||||
|
{#if !$mapStore.composing}
|
||||||
|
<button
|
||||||
|
class="fab"
|
||||||
|
onclick={() => mapStore.set(
|
||||||
|
{selectedMessage: null, composing: true })}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!--compose sheet (making a message)-->
|
||||||
|
{#if $mapStore.composing}
|
||||||
|
<ComposeSheet {lat} {lng} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -82,4 +100,25 @@
|
|||||||
color: #666;
|
color: #666;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #111;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||||
|
z-index: 150;
|
||||||
|
display: flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user