forked from orson/bachemap
started implementing the integrated camera function and the PWA
This commit is contained in:
parent
ea0b967366
commit
e6e6620eb7
28
app.py
28
app.py
@ -205,7 +205,9 @@ def create_app(config=Config):
|
||||
else:
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@app.route('/manifest.json')
|
||||
def manifest():
|
||||
return send_from_directory('static', 'manifest.json')
|
||||
|
||||
@app.route('/dashboard')
|
||||
@login_required
|
||||
@ -241,7 +243,31 @@ def create_app(config=Config):
|
||||
}
|
||||
mongo.db.users.insert_one(admin_user)
|
||||
print(f"Admin {username} added! Referral code is {admin_user['referral_code']}")
|
||||
@app.route("/camera", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def camera():
|
||||
if request.method == "POST":
|
||||
# 1. Grab geolocation data from the form
|
||||
latitude = request.form.get("latitude")
|
||||
longitude = request.form.get("longitude")
|
||||
|
||||
# 2. Grab the photo file
|
||||
photo = request.files.get("photo")
|
||||
|
||||
# 3. Save the file if it exists
|
||||
if photo:
|
||||
# Make sure your 'uploads' folder exists or handle dynamically
|
||||
photo_path = os.path.join(app.config['UPLOAD_FOLDER'], photo.filename)
|
||||
photo.save(photo_path)
|
||||
|
||||
# TODO: Optionally embed latitude/longitude into the photo’s EXIF here
|
||||
# using piexif or Pillow
|
||||
|
||||
# For demonstration, just redirect or return a success message
|
||||
return f"Photo uploaded! Lat: {latitude}, Lng: {longitude}"
|
||||
|
||||
# If GET request, just render the camera page
|
||||
return render_template("camera.html")
|
||||
@app.route('/leaderboard')
|
||||
def leaderboard():
|
||||
pipeline = [
|
||||
|
||||
BIN
static/images/contract.png
Normal file
BIN
static/images/contract.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
static/images/contract2.png
Normal file
BIN
static/images/contract2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
21
static/manifest.json
Normal file
21
static/manifest.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "El glorioso y nunca bien ponderado Bachemapa",
|
||||
"short_name": "Bachemapa",
|
||||
"start_url": "/camera",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/images/contract.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/images/contract2.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffd31d",
|
||||
"background_color": "#333",
|
||||
"display": "standalone"
|
||||
}
|
||||
42
static/service-worker.js
Normal file
42
static/service-worker.js
Normal file
@ -0,0 +1,42 @@
|
||||
|
||||
|
||||
const CACHE_NAME = 'bachemapa-pwa-v2';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/quienes',
|
||||
'/dashboard',
|
||||
'/leaderboard',
|
||||
'/thelogin',
|
||||
'/static/css/main.css',
|
||||
'/static/js/main.js',
|
||||
'/static/js/leaflet.js',
|
||||
'/static/css/styles.css',
|
||||
'/static/images/marker-icon.png',
|
||||
'/static/images/marker-shadow.png',
|
||||
'/static/images/leaflet.css',
|
||||
'/static/images/contract.png',
|
||||
'/static/images/contract2.png',
|
||||
'/static/manifest.json',
|
||||
'/manifest.json',
|
||||
'/static/service-worker.js'
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('Opened cache');
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return cached asset if available, otherwise fetch from network
|
||||
return response || fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
@ -302,7 +302,19 @@ section#pinner-modal::-webkit-scrollbar-thumb {
|
||||
.eaflet-popup-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 15px 30px;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 50px;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
z-index: 100;
|
||||
color: black;
|
||||
}
|
||||
.marker-cluster {
|
||||
background-color: rgb(249, 170, 61) !important;
|
||||
border-radius: 100px;
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
<script type="importmap" src="{{ url_for('static', filename='leaflet.markercluster.js.map')}}"></script>
|
||||
<link href="{{ url_for('static', filename='MarkerCluster.css')}}">
|
||||
<link href="{{ url_for('static', filename='MarkerCluster.Default.css')}}">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
|
||||
<meta name="description" content="Bachemapa es una plataforma dedicada a documentar y reportar problemas con el desarrollo urbano en Querétaro, México. Encuentra y aporta información actualizada sobre baches, deficiencias en infraestructura y más.">
|
||||
|
||||
@ -43,6 +44,8 @@
|
||||
|
||||
<!-- Otros meta tags -->
|
||||
<link rel="canonical" href="https://baches.qro.mx">
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
<!-- Versión pública 0.1 -->
|
||||
</head>
|
||||
@ -58,7 +61,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<main class="container-fluid" style="padding:0px;height: 100vh; width:100vw; z-index: 5; position: absolute;">
|
||||
<main class="container-fluid" style="padding:0px;height: 100vh; width:100vw; z-index: 5; position: static;">
|
||||
|
||||
{% block content %}
|
||||
|
||||
@ -115,5 +118,18 @@ window.addEventListener('load', function() {
|
||||
pinnerDesktop.addEventListener('click', toggleSlide);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/static/service-worker.js')
|
||||
.then(function(registration) {
|
||||
console.log('Service Worker registered with scope:', registration.scope);
|
||||
}, function(err) {
|
||||
console.log('Service Worker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</html>
|
||||
|
||||
185
templates/camera.html
Normal file
185
templates/camera.html
Normal file
@ -0,0 +1,185 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block head %}
|
||||
<title>Take a Photo with Geolocation</title>
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<style>
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#camera-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
#canvas {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#file-fallback {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
color: black;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="camera-container">
|
||||
<video id="video" autoplay playsinline></video>
|
||||
<canvas id="canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div style="position: absolute; bottom: 0; width: 100%; display: flex; flex-direction: column; padding: 10px; box-sizing: border-box;">
|
||||
<button id="capture-btn" class="control-button" style="margin: 10px;">Tomar foto</button>
|
||||
<div style="display: flex; justify-content: space-between; width: 100%; box-sizing: border-box;">
|
||||
<button id="retake-btn" class="control-button" style="display: none; margin: 10px 5px 10px 10px; flex: 1; left:20%; background-color: rgba(180,0,0,0.6);">Nueva foto</button>
|
||||
<button id="send-btn" class="control-button" style="display: none; margin: 10px 10px 10px 5px; flex: 1;">Enviar foto</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<form id="camera-form" action="{{ url_for('camera') }}" method="POST" enctype="multipart/form-data" style="display:none;">
|
||||
<input type="hidden" name="latitude" id="latitude" value="">
|
||||
<input type="hidden" name="longitude" id="longitude" value="">
|
||||
<input type="hidden" name="has_image" id="has_image" value="false">
|
||||
|
||||
<!-- Fallback file input for unsupported browsers -->
|
||||
<div id="file-fallback">
|
||||
<label for="photo">Take/Choose a Photo:</label>
|
||||
<input type="file" name="photo" id="photo" accept="image/*" capture="environment" required>
|
||||
<button type="submit">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Get DOM elements
|
||||
const video = document.getElementById('video');
|
||||
const canvas = document.getElementById('canvas');
|
||||
const captureBtn = document.getElementById('capture-btn');
|
||||
const retakeBtn = document.getElementById('retake-btn');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const form = document.getElementById('camera-form');
|
||||
const fallbackInput = document.getElementById('file-fallback');
|
||||
const hasImageInput = document.getElementById('has_image');
|
||||
|
||||
// Set up camera stream
|
||||
async function setupCamera() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: window.innerWidth },
|
||||
height: { ideal: window.innerHeight }
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
video.srcObject = stream;
|
||||
} catch (err) {
|
||||
console.error('Error accessing camera:', err);
|
||||
fallbackInput.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize camera if supported
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
setupCamera();
|
||||
} else {
|
||||
console.error('getUserMedia not supported');
|
||||
fallbackInput.style.display = 'block';
|
||||
}
|
||||
|
||||
// Capture photo from video feed
|
||||
captureBtn.addEventListener('click', function() {
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Stop camera stream
|
||||
video.srcObject.getTracks().forEach(track => track.stop());
|
||||
video.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
|
||||
// Update controls
|
||||
captureBtn.style.display = 'none';
|
||||
retakeBtn.style.display = 'inline-block';
|
||||
sendBtn.style.display = 'inline-block';
|
||||
|
||||
// Mark that we have an image
|
||||
hasImageInput.value = 'true';
|
||||
});
|
||||
|
||||
// Retake photo
|
||||
retakeBtn.addEventListener('click', function() {
|
||||
canvas.style.display = 'none';
|
||||
video.style.display = 'block';
|
||||
captureBtn.style.display = 'inline-block';
|
||||
retakeBtn.style.display = 'none';
|
||||
sendBtn.style.display = 'none';
|
||||
hasImageInput.value = 'false';
|
||||
setupCamera();
|
||||
});
|
||||
|
||||
// Send photo
|
||||
sendBtn.addEventListener('click', function() {
|
||||
if (hasImageInput.value === 'true') {
|
||||
// Convert canvas to blob and append to FormData
|
||||
canvas.toBlob(function(blob) {
|
||||
const formData = new FormData(form);
|
||||
formData.delete('photo'); // Remove any file input value
|
||||
formData.append('photo', blob, 'camera-capture.jpg');
|
||||
|
||||
// Submit form data via fetch
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
}
|
||||
}).catch(error => console.error('Error:', error));
|
||||
}, 'image/jpeg', 0.9);
|
||||
}
|
||||
});
|
||||
|
||||
// Request Geolocation Permission and Set Form Fields
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
document.getElementById('latitude').value = position.coords.latitude;
|
||||
document.getElementById('longitude').value = position.coords.longitude;
|
||||
},
|
||||
(error) => {
|
||||
console.error('Error getting location:', error);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 5000
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.error('Geolocation not supported in this browser.');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -72,6 +72,7 @@
|
||||
// Error handling for geolocation
|
||||
function onLocationError(e) {
|
||||
alert(e.message);
|
||||
map.setView([20.57, -100.38], 13); // Default to Querétaro's coordinates
|
||||
}
|
||||
|
||||
// Use Leaflet's built-in location detection
|
||||
@ -102,4 +103,10 @@
|
||||
user_radial.remove();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<div style="position: absolute; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 999;">
|
||||
<button class="control-button"><a href="/camera" class="btn btn-primary">Tomar foto</a></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@ -3,7 +3,7 @@
|
||||
<a href="/">
|
||||
<li style="display: flex; align-items: center;">
|
||||
<img id="logo" src="{{ url_for('static', filename='images/bachemapalogo.png') }}" alt="Bachemapa Logo" style="height: 10vh; margin-right: 10px;">
|
||||
<h3 style="margin-bottom: 0;"><span style="color: #4a8522;">Bache</span><span style="color: #fa8721;">mapa</span></h3>
|
||||
<h3 style="margin-bottom: 0;" id="sitename"><span style="color: #4a8522;">Bache</span><span style="color: #fa8721;">mapa</span></h3>
|
||||
</li>
|
||||
</a>
|
||||
</ul>
|
||||
@ -25,6 +25,7 @@
|
||||
{% endif %}
|
||||
<li><a href="/quienes" role="button" class="secondary">Somos</a></li>
|
||||
<li><a href="/leaderboard" role="button">Bachistas ★</a></li>
|
||||
<li><a style="border-radius: 100%; width: 2.5rem; height: 2.5rem; display: flex; align-items: center; justify-content: center; margin-right: 10px; text-decoration: none;" href="/quienes#tengopreguntas"><i style="color:white" class="fas fa-question-circle"></i></a></li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
@ -39,22 +40,27 @@
|
||||
{% endif %}
|
||||
<li><a href="/quienes" role="button" class="secondary">Somos</a></li>
|
||||
<li><a href="/leaderboard" role="button">Bachistas ★</a></li>
|
||||
</ul>
|
||||
</ul>
|
||||
<ul class="desktop-menu">
|
||||
<li><a style="border-radius: 100%; width: 2.5rem; height: 2.5rem; display: flex; align-items: center; justify-content: center; margin-right: 10px; text-decoration: none;" href="/quienes#tengopreguntas"><i style="color:white" class="fas fa-question-circle"></i></a></li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
@media (max-width: 715px) {
|
||||
.desktop-menu {
|
||||
display: none;
|
||||
}
|
||||
#sitename {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 716px) {
|
||||
.mobile-menu {
|
||||
display: none;
|
||||
}
|
||||
#sitename {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user