face detection & firestore & adding images to the messages
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
10
src/lib/Firebase/storage.js
Normal file
10
src/lib/Firebase/storage.js
Normal 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);
|
||||
}
|
||||
@@ -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);}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);}
|
||||
|
||||
44
src/lib/utils/faceDetection.js
Normal file
44
src/lib/utils/faceDetection.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user