face detection & firestore & adding images to the messages

This commit is contained in:
2026-06-09 20:53:40 +09:00
parent 69f7cbe833
commit 1f034d72a6
8 changed files with 249 additions and 26 deletions

7
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@googlemaps/js-api-loader": "^2.1.0",
"@mediapipe/tasks-vision": "^0.10.35",
"firebase": "^12.14.0",
"ngeohash": "^0.6.3"
},
@@ -755,6 +756,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.35",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
"license": "Apache-2.0"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",

View File

@@ -18,6 +18,7 @@
},
"dependencies": {
"@googlemaps/js-api-loader": "^2.1.0",
"@mediapipe/tasks-vision": "^0.10.35",
"firebase": "^12.14.0",
"ngeohash": "^0.6.3"
}

View File

@@ -0,0 +1,10 @@
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'
import { storage } from './config.js';
export async function uploadImage(file) {
//create unique file name and stores them in one folder in firebase
const filename = `messages/${Date.now()}_${file.name}`;
const storageRef = ref(storage, filename);
await uploadBytes(storageRef, file);
return getDownloadURL(storageRef);
}

View File

@@ -22,6 +22,9 @@
{#if message}
<div class="handle"> </div>
<div class="content">
{#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if}
<p class="message-text">{message.text}</p>
{#if decay}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
@@ -114,6 +117,14 @@
animation: pulsate 1.5s ease-in-out 3;
}
.message-img{
width: 100%;
max-height: 220px;
object-fit: cover;
border-radius: 12px;
margin-bottom: 0.75rem;
}
@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);}

View File

@@ -2,6 +2,9 @@
import { mapStore } from '$lib/stores/mapStore.js';
import { messagesStore } from '$lib/stores/messagesStore.js';
import { addMessage } from '$lib/firebase/messages.js'
import { hasFace } from '$lib/utils/faceDetection.js';
import { uploadImage } from '$lib/Firebase/storage.js';
let {lat, lng} = $props();
@@ -9,23 +12,78 @@
let submitting = $state(false);
let remaining = $derived(240-text.length);
let selectedFile = $state(null);
let imagePreview = $state(null); // what <img> will read
let imageError = $state(null);
let checkingFace = $state(false); // this will show the checking image... message
async function handleImageSelect(event) {
const file = event.target.files[0]; // only one file will be allowed so always index 0
if (!file) return; // no file do nothing
checkingFace = true; // show loading state
imageError = null; // clear errors
try {
// run face detection
const faceFound = await hasFace(file);
if (faceFound) {
// no upload & error
imageError = 'Images may not contain faces. Please choose another photo.';
//reset file input for other attempt
event.target.value = '';
selectedFile= null;
imagePreview = null;
} else {
// no face we move on
selectedFile = file;
// create preview
imagePreview = URL.createObjectURL(file);
}
} catch (err) {
// fixed: previously allowed the upload on any detection error — wrong for a
// face-blocking feature. Now we surface the error and block the upload instead.
console.error('[faceDetection] error:', err);
imageError = 'Could not verify the image. Please try a different photo.';
event.target.value = '';
selectedFile = null;
imagePreview = null;
} finally {
// no matter what turn the loading state off
checkingFace = false;
}
}
async function handleSubmit() {
// no submit with no text or text over limit
if (!text.trim() || remaining < 0) return;
submitting = true;
let imageUrl = ''; // stays empty if no image
await addMessage(lat, lng, text.trim());
if (selectedFile) {
imageUrl = await uploadImage(selectedFile);
}
//refresh the messages store so pin is there
const { getNearbyMessages } = await import('$lib/firebase/messages.js');
await addMessage(lat, lng, text.trim(), imageUrl);
// reload pins so that they show up with new message
const { getNearbyMessages } = await import ('$lib/firebase/messages.js');
const updated = await getNearbyMessages(lat, lng);
messagesStore.set(updated);
//reset and close
// reset compose state
text = '';
selectedFile = null;
imagePreview = null;
submitting = false;
mapStore.set({selectedMessage: null, composing: false});
}
// close the sheet
mapStore.set({selectedMessage: null, composing:false});
}
</script>
<div class="compose" class:visible={true}>
@@ -45,6 +103,35 @@
rows="5"
></textarea>
<div class = "image-section">
{#if checkingFace}
<p class="checking">Checking image...</p>
{:else if imagePreview}
<div class="preview-wrap">
<img class="preview" src={imagePreview} alt="preview" />
<button class="remove-image" onclick={() => { selectedFile = null; imagePreview = null; }}>
X
</button>
</div>
{:else}
{#if imageError}
<p class="image-error">{imageError}</p>
{/if}
<label class="image-label">
+ add photo
<input
type="file"
accept="image/*"
onchange={handleImageSelect}
style="display:none"
/>
</label>
{/if}
</div>
<button
class="submit"
onclick={handleSubmit}
@@ -134,5 +221,60 @@
background: #ccc;
cursor: not-allowed;
}
.image-section {
margin-top: 0.75rem;
}
.image-label {
display: inline-block;
paddingL 0.5rem 1rem;
border: 1.5px dashed #ddd;
border-radius: 8px;
font-size: 0.85rem;
color: #999;
cursor: pointer;
}
.image-label:hover {
border-color: #111;
color: #111;
}
.preview-wrap {
position: relative;
display: inline-block;
}
.preview {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 10px;
}
.remove-image {
position: absolute;
top: 6px;
right: 6px;
background: rgba(0,0,0,0.5);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 0.7rem;
cursor: pointer;
}
.checking {
font-size: 0.85rem;
color: #999;
}
.image-error {
font-size: 0.85rem;
color: #e74c3c;
}
</style>

View File

@@ -13,23 +13,18 @@
let markers = []; // keep track of pins on map
let userMarker;
let AdvancedMarkerElement;
/** Jisu Legacy - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
function addUserLocationMarker(map, centerLat, centerLng) {
userMarker = new google.maps.Marker({
const dot = document.createElement('div');
dot.style.cssText = 'width:20px;height:20px;border-radius:50%;background:#4285F4;border:3px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.3)';
userMarker = new AdvancedMarkerElement({
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
}
content: dot
});
}
@@ -44,35 +39,37 @@
});
const { Map } = await importLibrary('maps');
({ AdvancedMarkerElement } = await importLibrary('marker'));
mapDiv = new Map(mapDiv, {
center: { lat: centerLat, lng: centerLng },
zoom: 15,
disableDefaultUI: true,
gestureHandling: 'greedy'
gestureHandling: 'greedy',
mapId: 'DEMO_MAP_ID'
});
addUserLocationMarker(mapDiv, centerLat, centerLng);
});
// function to rended pins
// function to render pins
function renderPins(messages) {
// clear current pins
markers.forEach(marker => marker.setMap(null)); // make them not show up
markers = []; // reset the array
markers.forEach(marker => (marker.map = null));
markers = [];
messages.forEach(message => {
const marker = new google.maps.Marker({
position: { lat: message.lat, lng: message.lng}, // lat and lng is what is called in the firestore documents
const marker = new AdvancedMarkerElement({
position: { lat: message.lat, lng: message.lng },
map: mapDiv,
title: message.text // firestore field for messages
title: message.text
});
marker.addListener('click', () => {
mapStore.set({ selectedMessage: message, composing: false}); //it updated the message object
marker.addEventListener('click', () => {
mapStore.set({ selectedMessage: message, composing: false });
});
markers.push(marker); // add the new pin to the array
markers.push(marker);
});
}

View File

@@ -20,6 +20,9 @@
<div class = "panel">
{#if message}
<div class="content">
{#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if}
<p class="message-text">{message.text}</p>
{#if decay}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
@@ -111,6 +114,14 @@
animation: pulsate 1.5s ease-in-out 3;
}
.message-img{
width: 100%;
max-height: 220px;
object-fit: cover;
border-radius: 12px;
margin-bottom: 0.75rem;
}
@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);}

View File

@@ -0,0 +1,44 @@
import { FaceDetector, FilesetResolver } from '@mediapipe/tasks-vision';
let faceDetector = null;
export async function loadFaceDetector() {
if (faceDetector) return;
console.log('[faceDetection] loading model...');
const vision = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm'
);
faceDetector = await FaceDetector.createFromOptions(vision, {
baseOptions: {
modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite'
},
runningMode: 'IMAGE'
});
console.log('[faceDetection] model ready');
}
// fixed: parameter was named 'File' (capital F) but used as 'file' below — caused ReferenceError
// that fell silently into the ComposeSheet catch block, allowing every upload through
export async function hasFace(file) {
await loadFaceDetector();
const objectUrl = URL.createObjectURL(file);
const img = document.createElement('img');
// fixed: onload/onerror must be assigned BEFORE setting img.src — if the object URL
// resolves synchronously the load event fires before the handler was attached and
// the Promise never settles, leaving detection running on an unloaded element
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = () => reject(new Error('Image failed to load'));
img.src = objectUrl;
});
const result = faceDetector.detect(img);
URL.revokeObjectURL(objectUrl);
console.log('[faceDetection] detections:', result.detections);
return result.detections.length > 0;
}