Compare commits

2 Commits

Author SHA1 Message Date
2b4a005407 second: open camera 2026-06-08 16:23:24 +09:00
15be996704 first : correct a typo(filename) 2026-06-06 16:45:33 +09:00
13 changed files with 354 additions and 342 deletions

View File

@@ -1,28 +1,59 @@
import { browser } from '$app/environment';
// we get all our firebase config from the .env file, not included in the repo, so we don't share our API keys. import { initializeApp, getApps } from 'firebase/app';
// 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 { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage'; import { getStorage } from 'firebase/storage';
import { import { env } from '$env/dynamic/public';
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';
const firebaseConfig = { /** Firebase 환경 변수가 모두 설정됐는지 확인 */
apiKey: PUBLIC_FIREBASE_API_KEY, export function isFirebaseConfigured() {
authDomain: PUBLIC_FIREBASE_AUTH_DOMAIN, return Boolean(
projectId: PUBLIC_FIREBASE_PROJECT_ID, env.PUBLIC_FIREBASE_API_KEY &&
storageBucket: PUBLIC_FIREBASE_STORAGE_BUCKET, env.PUBLIC_FIREBASE_AUTH_DOMAIN &&
messagingSenderId: PUBLIC_FIREBASE_MESSAGING_SENDER_ID, env.PUBLIC_FIREBASE_PROJECT_ID &&
appId: PUBLIC_FIREBASE_APP_ID env.PUBLIC_FIREBASE_STORAGE_BUCKET &&
}; env.PUBLIC_FIREBASE_MESSAGING_SENDER_ID &&
env.PUBLIC_FIREBASE_APP_ID
);
}
const app = initializeApp(firebaseConfig); function getFirebaseConfig() {
export const db = getFirestore(app); return {
export const storage = getStorage(app); 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;
}

View File

@@ -1,51 +1,24 @@
import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; // tools for building and running db queries import { collection, query, where, getDocs } from 'firebase/firestore';
import { db } from './config'; // database connection import { getDb, isFirebaseConfigured } from './config.js';
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string import { getQueryPrefix } from '$lib/utils/geohash.js';
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); if (!isFirebaseConfigured()) {
console.warn(
'Firebase가 설정되지 않아 메시지를 불러오지 못했습니다. 프로젝트 루트에 .env 파일을 추가하세요.'
);
return [];
}
const q = query( const prefix = getQueryPrefix(lat, lng);
collection(db, 'messages'), const db = getDb();
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
);
const snapshot = await getDocs(q); const q = query(
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); collection(db, 'messages'),
} where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
export async function echoMessage(messageId) { );
const ref = doc(db, 'messages', messageId);
await updateDoc(ref, { const snapshot = await getDocs(q);
echoCount: increment(1), return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
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;
} }

View File

View File

View File

@@ -0,0 +1,165 @@
<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>

View File

@@ -1,20 +1,7 @@
<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 -->
@@ -23,16 +10,11 @@
<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>
{#if decay} <p class="meta">
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p> left {message.lat.toFixed(4)}, {message.lng.toFixed(4)}
{/if} </p>
<div class="actions"> <div class="actions">
<button class="echo-button" <button class="echo-button">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
@@ -109,13 +91,4 @@
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>

View File

@@ -1,138 +0,0 @@
<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>

View File

@@ -10,9 +10,28 @@
let { lat, lng } = $props(); let { lat, lng } = $props();
let mapDiv; let mapDiv;
let userMarker = null;
let markers = []; // keep track of pins on map 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 () => { onMount (async () => {
const centerLat = Number(lat); const centerLat = Number(lat);
const centerLng = Number(lng); const centerLng = Number(lng);
@@ -31,6 +50,8 @@
disableDefaultUI: true, disableDefaultUI: true,
gestureHandling: 'greedy' gestureHandling: 'greedy'
}); });
addUserLocationMarker(mapDiv, centerLat, centerLng);
}); });
// function to rended pins // function to rended pins

View File

@@ -1,36 +1,18 @@
<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>
{#if decay} <p class="meta">
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p> left {message.lat.toFixed(4)}, {message.lng.toFixed(4)}
{/if} </p>
<div class="actions"> <div class="actions">
<button class="echo-button" <button class="echo-button">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,7 +72,7 @@
.letgo-button { .letgo-button {
flex: 1; flex: 1;
padding: 0..75rem; padding: 0.75rem;
background: transparent; background: transparent;
color: #111; color: #111;
border: 1.5px solid #ddd; border: 1.5px solid #ddd;
@@ -106,14 +88,5 @@
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>

View File

@@ -0,0 +1,9 @@
import { writable } from 'svelte/store';
export const arStore = writable({
isARMode: false,
currentHeading: null,
selectedScreenPoint: null,
composing: false,
focusedMessage: null
});

View File

@@ -1,15 +0,0 @@
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
};
}

View File

@@ -1,16 +1,17 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import MapView from '$lib/components/MapView.svelte'; import MapView from '$lib/components/MapView.svelte';
import ARView from '$lib/components/ARView.svelte';
import { getNearbyMessages } from '$lib/firebase/messages.js'; import { getNearbyMessages } from '$lib/firebase/messages.js';
import { messagesStore } from '$lib/stores/messagesStore.js'; import { messagesStore } from '$lib/stores/messagesStore.js';
import { mapStore } from '$lib/stores/mapStore.js'; import { mapStore } from '$lib/stores/mapStore.js';
import { arStore } from '$lib/stores/arStore.js';
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();
@@ -19,28 +20,36 @@
let isMobile = $derived(windowWidth < 768); let isMobile = $derived(windowWidth < 768);
onMount(() => { async function loadLocation() {
if (!navigator.geolocation) { if (!navigator.geolocation) {
error = "Your browser doesn't support geolocation :("; error = "Your browser doesn't support geolocation :(";
return; // do nothing return;
} }
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( navigator.geolocation.getCurrentPosition(
async (position) => { async (position) => {
const messages = await getNearbyMessages(position.coords.latitude, position.coords.longitude); lat = position.coords.latitude;
lng = position.coords.longitude;
const messages = await getNearbyMessages(lat, lng);
messagesStore.set(messages); messagesStore.set(messages);
console.log('messages loaded:', $messagesStore); },
} (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 }
); );
}
onMount(() => {
loadLocation();
}); });
@@ -49,16 +58,28 @@
<svelte:window bind:innerWidth={windowWidth} /> <!--this sends the windowWidth to our mobile checker --> <svelte:window bind:innerWidth={windowWidth} /> <!--this sends the windowWidth to our mobile checker -->
{#if error} {#if error}
<p class="error">{error}</p> <div class="error">
<p>{error}</p>
<button class="retry-button" onclick={loadLocation}>Try again</button>
</div>
{:else if lat && lng} {:else if lat && lng}
{#if $arStore.isARMode}
<ARView {lat} {lng} />
{:else}
<MapView {lat} {lng} /> <MapView {lat} {lng} />
{/if}
{:else} {:else}
<p class="loading">Looking for you...</p> <p class="loading">Looking for you...</p>
{/if} {/if}
<!-- map must fill the whole screen--> <!--showing AR button only in mapview-->
{#if lat && lng} {#if lat && lng && !$arStore.isARMode}
<MapView {lat} {lng} /> <button
class="ar-button"
onclick={() => arStore.update((state) => ({ ...state, isARMode: true }))}
>
AR
</button>
{/if} {/if}
<!-- show the right panel based on mobile or desktop--> <!-- show the right panel based on mobile or desktop-->
@@ -68,22 +89,6 @@
<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;
@@ -93,32 +98,47 @@
.error, .loading { .error, .loading {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1rem;
height: 100vh; height: 100vh;
padding: 0 1.5rem;
text-align: center;
font-family: Georgia, 'Times New Roman', Times, serif; font-family: Georgia, 'Times New Roman', Times, serif;
color: #666; color: #666;
} }
.fab { .retry-button {
position: fixed; padding: 0.75rem 1.25rem;
bottom: 2rem; border: none;
right: 1.5rem; border-radius: 10px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #111; background: #111;
color: white; color: white;
font-size: 1.8rem; font-size: 0.95rem;
border: none;
cursor: pointer; 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;
} }
.ar-button {
position: fixed;
right: 20px;
bottom: 28px;
z-index: 20;
width: 64px;
height: 64px;
border-radius: 50%;
border: none;
background: #111;
color: white;
font-size: 18px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.ar-button:active {
transform: scale(0.96);
}
</style> </style>