forked from 20266142/Overheard
Compare commits
1 Commits
2b4a005407
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 11d5d7f493 |
@@ -1,59 +1,28 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeApp, getApps } from 'firebase/app';
|
||||
|
||||
// we get all our firebase config from the .env file, not included in the repo, so we don't share our API keys.
|
||||
// then we initialize the app and export the database and storage so we can use them in other files
|
||||
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
import { getStorage } from 'firebase/storage';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import {
|
||||
PUBLIC_FIREBASE_API_KEY,
|
||||
PUBLIC_FIREBASE_AUTH_DOMAIN,
|
||||
PUBLIC_FIREBASE_PROJECT_ID,
|
||||
PUBLIC_FIREBASE_STORAGE_BUCKET,
|
||||
PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
|
||||
PUBLIC_FIREBASE_APP_ID
|
||||
} from '$env/static/public';
|
||||
|
||||
/** Firebase 환경 변수가 모두 설정됐는지 확인 */
|
||||
export function isFirebaseConfigured() {
|
||||
return Boolean(
|
||||
env.PUBLIC_FIREBASE_API_KEY &&
|
||||
env.PUBLIC_FIREBASE_AUTH_DOMAIN &&
|
||||
env.PUBLIC_FIREBASE_PROJECT_ID &&
|
||||
env.PUBLIC_FIREBASE_STORAGE_BUCKET &&
|
||||
env.PUBLIC_FIREBASE_MESSAGING_SENDER_ID &&
|
||||
env.PUBLIC_FIREBASE_APP_ID
|
||||
);
|
||||
}
|
||||
const firebaseConfig = {
|
||||
apiKey: PUBLIC_FIREBASE_API_KEY,
|
||||
authDomain: PUBLIC_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: PUBLIC_FIREBASE_PROJECT_ID,
|
||||
storageBucket: PUBLIC_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: PUBLIC_FIREBASE_APP_ID
|
||||
};
|
||||
|
||||
function getFirebaseConfig() {
|
||||
return {
|
||||
apiKey: env.PUBLIC_FIREBASE_API_KEY,
|
||||
authDomain: env.PUBLIC_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: env.PUBLIC_FIREBASE_PROJECT_ID,
|
||||
storageBucket: env.PUBLIC_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: env.PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: env.PUBLIC_FIREBASE_APP_ID
|
||||
};
|
||||
}
|
||||
|
||||
let app;
|
||||
let db;
|
||||
let storage;
|
||||
|
||||
/** Firebase 앱을 지연 초기화하고 Firestore 인스턴스를 반환 */
|
||||
export function getDb() {
|
||||
if (!browser) {
|
||||
throw new Error('Firestore는 브라우저에서만 사용할 수 있습니다.');
|
||||
}
|
||||
|
||||
if (!isFirebaseConfigured()) {
|
||||
throw new Error(
|
||||
'Firebase 환경 변수가 설정되지 않았습니다. .env.example을 참고해 .env 파일을 만드세요.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
app = getApps().length ? getApps()[0] : initializeApp(getFirebaseConfig());
|
||||
db = getFirestore(app);
|
||||
storage = getStorage(app);
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Firebase Storage 인스턴스 반환 */
|
||||
export function getStorageInstance() {
|
||||
getDb();
|
||||
return storage;
|
||||
}
|
||||
const app = initializeApp(firebaseConfig);
|
||||
export const db = getFirestore(app);
|
||||
export const storage = getStorage(app);
|
||||
@@ -1,24 +1,51 @@
|
||||
import { collection, query, where, getDocs } from 'firebase/firestore';
|
||||
import { getDb, isFirebaseConfigured } from './config.js';
|
||||
import { getQueryPrefix } from '$lib/utils/geohash.js';
|
||||
import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; // tools for building and running db queries
|
||||
import { db } from './config'; // database connection
|
||||
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) {
|
||||
if (!isFirebaseConfigured()) {
|
||||
console.warn(
|
||||
'Firebase가 설정되지 않아 메시지를 불러오지 못했습니다. 프로젝트 루트에 .env 파일을 추가하세요.'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const prefix = getQueryPrefix(lat, lng);
|
||||
|
||||
const prefix = getQueryPrefix(lat, lng);
|
||||
const db = getDb();
|
||||
const q = query(
|
||||
collection(db, 'messages'),
|
||||
where('geohash', '>=', prefix),
|
||||
where('geohash', '<', prefix + 'z')
|
||||
);
|
||||
|
||||
const q = query(
|
||||
collection(db, 'messages'),
|
||||
where('geohash', '>=', prefix),
|
||||
where('geohash', '<', prefix + 'z')
|
||||
);
|
||||
|
||||
const snapshot = await getDocs(q);
|
||||
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
|
||||
const snapshot = await getDocs(q);
|
||||
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,165 +0,0 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { arStore } from '$lib/stores/arStore.js';
|
||||
|
||||
let { lat, lng } = $props();
|
||||
|
||||
let videoElement;
|
||||
let cameraStream = null;
|
||||
let cameraError = '';
|
||||
|
||||
async function startCamera() {
|
||||
try {
|
||||
cameraStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' }
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = cameraStream;
|
||||
await videoElement.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Camera error:', error);
|
||||
cameraError = 'Camera could not be started.';
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (cameraStream) {
|
||||
cameraStream.getTracks().forEach((track) => track.stop());
|
||||
cameraStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
function exitARMode() {
|
||||
stopCamera();
|
||||
|
||||
arStore.update((state) => ({
|
||||
...state,
|
||||
isARMode: false,
|
||||
selectedScreenPoint: null,
|
||||
composing: false,
|
||||
focusedMessage: null
|
||||
}));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startCamera();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopCamera();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ar-view">
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
class="camera-video"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
|
||||
<div class="ar-overlay">
|
||||
<button class="back-button" onclick={exitARMode}>
|
||||
Back
|
||||
</button>
|
||||
|
||||
{#if cameraError}
|
||||
<div class="camera-error">
|
||||
<p>{cameraError}</p>
|
||||
<p class="small">Please check camera permission in your browser.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="ar-status">
|
||||
<p>AR Mode</p>
|
||||
<p class="small">Move your camera slowly.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ar-view {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
color: white;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.ar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 16px;
|
||||
z-index: 10;
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #111;
|
||||
font-weight: 600;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ar-status {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 36px;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(8px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ar-status p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.small {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.camera-error {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 20px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: #111;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.camera-error p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,20 @@
|
||||
<script>
|
||||
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 decay = $derived(
|
||||
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
||||
);
|
||||
|
||||
let echoed = $state(false);
|
||||
|
||||
async function handleEcho() {
|
||||
await echoMessage(message.id);
|
||||
echoed = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- if message exists, sheet is visible -->
|
||||
@@ -10,11 +23,16 @@
|
||||
<div class="handle"> </div>
|
||||
<div class="content">
|
||||
<p class="message-text">{message.text}</p>
|
||||
<p class="meta">
|
||||
left {message.lat.toFixed(4)}, {message.lng.toFixed(4)}
|
||||
</p>
|
||||
{#if decay}
|
||||
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||
{/if}
|
||||
<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(
|
||||
{ selectedMessage: null, composing: false })}>
|
||||
let go
|
||||
@@ -90,5 +108,14 @@
|
||||
font-size: 0.95rem;
|
||||
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>
|
||||
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>
|
||||
|
||||
@@ -10,28 +10,9 @@
|
||||
let { lat, lng } = $props();
|
||||
|
||||
let mapDiv;
|
||||
let userMarker = null;
|
||||
|
||||
let markers = []; // keep track of pins on map
|
||||
|
||||
/** 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
|
||||
function addUserLocationMarker(map, centerLat, centerLng) {
|
||||
userMarker = new google.maps.Marker({
|
||||
position: { lat: centerLat, lng: centerLng },
|
||||
map,
|
||||
title: 'Your location',
|
||||
zIndex: 1000,
|
||||
icon: {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
scale: 10,
|
||||
fillColor: '#4285F4',
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#ffffff',
|
||||
strokeWeight: 3
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount (async () => {
|
||||
const centerLat = Number(lat);
|
||||
const centerLng = Number(lng);
|
||||
@@ -50,8 +31,6 @@
|
||||
disableDefaultUI: true,
|
||||
gestureHandling: 'greedy'
|
||||
});
|
||||
|
||||
addUserLocationMarker(mapDiv, centerLat, centerLng);
|
||||
});
|
||||
|
||||
// function to rended pins
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
<script>
|
||||
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 decay = $derived(
|
||||
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
||||
);
|
||||
|
||||
let echoed = $state(false);
|
||||
|
||||
async function handleEcho() {
|
||||
await echoMessage(message.id);
|
||||
echoed = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class = "panel">
|
||||
{#if message}
|
||||
<div class="content">
|
||||
<p class="message-text">{message.text}</p>
|
||||
<p class="meta">
|
||||
left {message.lat.toFixed(4)}, {message.lng.toFixed(4)}
|
||||
</p>
|
||||
{#if decay}
|
||||
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||
{/if}
|
||||
<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(
|
||||
{selectedMessage: null, composing: false})}>
|
||||
Let go
|
||||
@@ -72,7 +90,7 @@
|
||||
|
||||
.letgo-button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
padding: 0..75rem;
|
||||
background: transparent;
|
||||
color: #111;
|
||||
border: 1.5px solid #ddd;
|
||||
@@ -87,6 +105,15 @@
|
||||
margin-top: 2rem;
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const arStore = writable({
|
||||
isARMode: false,
|
||||
currentHeading: null,
|
||||
selectedScreenPoint: null,
|
||||
composing: false,
|
||||
focusedMessage: null
|
||||
});
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
<script>
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import MapView from '$lib/components/MapView.svelte';
|
||||
import ARView from '$lib/components/ARView.svelte';
|
||||
|
||||
import { getNearbyMessages } from '$lib/firebase/messages.js';
|
||||
import { messagesStore } from '$lib/stores/messagesStore.js';
|
||||
import { mapStore } from '$lib/stores/mapStore.js';
|
||||
import { arStore } from '$lib/stores/arStore.js';
|
||||
|
||||
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();
|
||||
@@ -20,36 +19,28 @@
|
||||
|
||||
let isMobile = $derived(windowWidth < 768);
|
||||
|
||||
async function loadLocation() {
|
||||
onMount(() => {
|
||||
if (!navigator.geolocation) {
|
||||
error = "Your browser doesn't support geolocation :(";
|
||||
return;
|
||||
return; // do nothing
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
(position) => {
|
||||
lat = position.coords.latitude;
|
||||
lng = position.coords.longitude;
|
||||
|
||||
const messages = await getNearbyMessages(lat, lng);
|
||||
messagesStore.set(messages);
|
||||
},
|
||||
(geoError) => {
|
||||
if (geoError.code === geoError.PERMISSION_DENIED) {
|
||||
error =
|
||||
'Location access is blocked. In Chrome, click the tune icon next to the URL, open Site settings, and set Location to Allow. Then refresh this page.';
|
||||
} else if (geoError.code === geoError.POSITION_UNAVAILABLE) {
|
||||
error = 'Location unavailable. Check that location services are enabled on your device.';
|
||||
} else {
|
||||
error = 'Could not get your location. Please try again.';
|
||||
}
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
() => {
|
||||
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);
|
||||
messagesStore.set(messages);
|
||||
console.log('messages loaded:', $messagesStore);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadLocation();
|
||||
});
|
||||
|
||||
|
||||
@@ -58,28 +49,16 @@
|
||||
<svelte:window bind:innerWidth={windowWidth} /> <!--this sends the windowWidth to our mobile checker -->
|
||||
|
||||
{#if error}
|
||||
<div class="error">
|
||||
<p>{error}</p>
|
||||
<button class="retry-button" onclick={loadLocation}>Try again</button>
|
||||
</div>
|
||||
<p class="error">{error}</p>
|
||||
{:else if lat && lng}
|
||||
{#if $arStore.isARMode}
|
||||
<ARView {lat} {lng} />
|
||||
{:else}
|
||||
<MapView {lat} {lng} />
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="loading">Looking for you...</p>
|
||||
<p class="loading">Looking for you...</p>
|
||||
{/if}
|
||||
|
||||
<!--showing AR button only in mapview-->
|
||||
{#if lat && lng && !$arStore.isARMode}
|
||||
<button
|
||||
class="ar-button"
|
||||
onclick={() => arStore.update((state) => ({ ...state, isARMode: true }))}
|
||||
>
|
||||
AR
|
||||
</button>
|
||||
<!-- map must fill the whole screen-->
|
||||
{#if lat && lng}
|
||||
<MapView {lat} {lng} />
|
||||
{/if}
|
||||
|
||||
<!-- show the right panel based on mobile or desktop-->
|
||||
@@ -89,6 +68,22 @@
|
||||
<SidePanel message={$mapStore.selectedMessage} />
|
||||
{/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>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
@@ -98,47 +93,32 @@
|
||||
|
||||
.error, .loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
height: 100vh;
|
||||
padding: 0 1.5rem;
|
||||
text-align: center;
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
color: #666;
|
||||
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #111;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ar-button {
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 28px;
|
||||
z-index: 20;
|
||||
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
bottom: 2rem;
|
||||
right: 1.5rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
|
||||
background: #111;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
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;
|
||||
}
|
||||
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.ar-button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user