From db45e5c2abd8876b06b4b7e232c9f1f4a6514e75 Mon Sep 17 00:00:00 2001 From: irenecancode <196759302+irenecancode@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:43:54 -0700 Subject: [PATCH 1/4] feature/report sighting: multi-step wizard + smart auto-fill --- .../templates/sightings/create_sighting.html | 1789 +++++++++++++---- 1 file changed, 1414 insertions(+), 375 deletions(-) diff --git a/siteapps/templates/sightings/create_sighting.html b/siteapps/templates/sightings/create_sighting.html index fe89931..0b9dd97 100644 --- a/siteapps/templates/sightings/create_sighting.html +++ b/siteapps/templates/sightings/create_sighting.html @@ -36,6 +36,191 @@ .required-field::after { content: " *"; color: red; + } + .date-field-locked { + background-color: #e9ecef !important; + color: #495057; + } + .media-remove-btn { + width: 1.75rem; + height: 1.75rem; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(0, 0, 0, 0.35); + border: none; + color: rgba(255, 255, 255, 0.75); + opacity: 0.55; + transition: opacity 0.15s ease, background-color 0.15s ease; + } + .media-remove-btn i { + font-size: 0.8rem; + } + .media-remove-btn:hover, + .media-remove-btn:focus { + opacity: 1; + background: rgba(220, 53, 69, 0.85); + color: #fff; + } + + /* ---------------- Wizard progress bar ---------------- */ + .wizard-progress { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1rem; + padding: 0 0.5rem; + } + .wizard-progress .wp-step { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + position: relative; + cursor: default; + } + .wizard-progress .wp-step.completed { + cursor: pointer; + } + .wizard-progress .wp-step .wp-circle { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + background: rgba(255,255,255,0.15); + border: 2px solid rgba(255,255,255,0.45); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + color: #fff; + transition: all 0.2s ease; + position: relative; + z-index: 2; + } + .wizard-progress .wp-step .wp-label { + margin-top: 0.5rem; + font-size: 0.8rem; + text-align: center; + color: #fff; + font-weight: 500; + max-width: 6rem; + } + .wizard-progress .wp-step:not(:last-child)::after { + content: ''; + position: absolute; + top: 1.25rem; + left: 50%; + width: 100%; + height: 2px; + background: rgba(255,255,255,0.3); + z-index: 1; + } + .wizard-progress .wp-step.active .wp-circle { + background: var(--primary-light, #69a68a); + border-color: var(--primary-light, #69a68a); + box-shadow: 0 0 0 4px rgba(105,166,138,0.3); + } + .wizard-progress .wp-step.completed .wp-circle { + background: var(--primary-dark, #34483f); + border-color: var(--primary-dark, #34483f); + } + .wizard-progress .wp-step.completed:not(:last-child)::after { + background: var(--primary-dark, #34483f); + } + .wizard-progress .wp-step .wp-check { display: none; } + .wizard-progress .wp-step.completed .wp-num { display: none; } + .wizard-progress .wp-step.completed .wp-check { display: inline; } + .wizard-step { animation: wizardFadeIn 0.25s ease; } + @keyframes wizardFadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } + } + .preview-card { + background: #f3f8f5; + border: 1px solid var(--primary-light, #69a68a); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 1.5rem; + } + /* Feed-style preview card — mirrors the sighting feed card layout so + Preview & Publish reads like the real post: media on top, then title, + byline, description and location, all editable in place. */ + .feed-preview-card { + padding: 0; + overflow: hidden; + background: #fff; + } + .feed-card-media { + position: relative; + width: 100%; + height: 220px; + background: #dcdfe1; + display: flex; + align-items: center; + justify-content: center; + color: #8a9096; + overflow: hidden; + } + .feed-card-media img { + width: 100%; + height: 100%; + object-fit: cover; + } + .feed-video-badge { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: rgba(0, 0, 0, 0.65); + color: #fff; + border-radius: 999px; + padding: 0.2rem 0.65rem; + font-size: 0.75rem; + display: flex; + align-items: center; + gap: 0.3rem; + } + .feed-play-btn { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + background: rgba(0, 0, 0, 0.4); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + } + .feed-card-body { + padding: 1.25rem; + } + .feed-title-input { + border: none; + padding: 0.15rem 0; + font-size: 1.25rem; + font-weight: 600; + color: #1a1a1a; + } + .feed-title-input:focus { + box-shadow: none; + outline: 1px dashed var(--primary-light, #69a68a); + outline-offset: 2px; + } + .feed-description-input { + border: none; + padding: 0.15rem 0; + resize: vertical; + color: #333; + } + .feed-description-input:focus { + box-shadow: none; + outline: 1px dashed var(--primary-light, #69a68a); + outline-offset: 2px; } {% endblock header_includes %} @@ -53,312 +238,476 @@

Report a Wildlife Sighting

{% endfor %} {% endif %} + + +
+
+
1
+
Media
+
+
+
2
+
Time & Place
+
+
+
3
+
Species
+
+
+
4
+
Device
+
+
+
5
+
Preview
+
+
+

Step 1 of 5 — Media Upload

+
{% csrf_token %} - -
-

- Media Upload -

-
- - - Upload up to 5 images or videos (max 500MB each). Supported formats: JPEG, PNG, MP4, WebM, MOV, AVI -
-
-
- -
-

- Species Identification -

-

- Add up to 5 species observed in this sighting. The first entry is the primary identification. -

-
- -
-
- - 1 of 5 max + + +
+
+

+ Media Upload +

+
+ + + Upload up to 5 images or videos (max 500MB each). Supported formats: JPEG, PNG, MP4, WebM, MOV, AVI +
+
+
- -
-

- Location -

-
- - - Select a previously saved location or enter coordinates manually below. Manage saved locations -
-
-
- - + + +
+
+

+ Time & Place +

+
+ +
+ + + + +
-
- - +
+ + + Select a previously saved location, or switch to "New Location" to enter one manually. Manage saved locations
-
-
- +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + Auto-filled from your media. Camera clock off? Adjust it below. + +
+ + +
+
+

If your camera time was incorrect, record the error here:

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
-
-
-
- - + + How did you observe this wildlife?
-
- +
+ + max="9999" + value="1"> + How many individual animals were visible? +
+

+ Add up to 5 species observed in this sighting. The first entry is the primary identification. +

+
+ +
+
+ + 1 of 5 max +
+
+ +
-
-
- -
- -
-

- When Did You See It? -

-
-
- - + + +
+
+

+ Device & Credit +

+
+ +
+ + + + + + + + +
-
- - + +
+ + + + +
+
+ +
+
+
+ + + id="camera_model" + name="camera_model" + placeholder="e.g., Canon EOS 5D Mark IV"> + Detected automatically from photo EXIF data when available.
-
- -
- -
-

- Description -

-
- - - 0/200 characters -
-
- - - 0/2000 characters -
-
- -
-

- Additional Information -

-
- - - How did you observe this wildlife? -
-
- - - How many individual animals were visible? -
-
- - -
-
- - -
-
- -

If your camera time was incorrect, record the error here:

-
-
- +
+
+ + + License applied to this sighting's data and media. Your default is set on your profile page. +
+
+
+ + id="new_device_name" + placeholder="e.g., Backyard Trail Cam" + maxlength="100">
-
- - +
+ +
-
-
- +
+
+ + +
+
+
+
+ + +
+
+

+ Preview & Publish +

+
+
+ +
+
+ + class="form-control feed-title-input" + id="post_title" + name="post_title" + maxlength="200" + required + placeholder="Give your sighting a descriptive title"> + 0/200 characters + + + + 0/2000 characters +
+ Not specified +
+
+ Species not specified + · + Camera not specified + · + — +
-
- +
+
+
+ + +
+
+ + class="form-control" + id="attribution_override" + name="attribution_override" + placeholder="Leave blank to use your display name"> +
+
+
+
+
+
+ +
+ + + + +
+
+ + + Used only when Public Setting is Approximate. +
+
+ + +
-
-
- - - License applied to this sighting's data and media. Your default is set on your profile page. -
-
- - - Custom name to use for attribution (e.g. your organization). Leave blank to use your display name.
+ {% if is_expert %} - + +

+ Optional expert tool below — works independently of the steps above. +

Bulk Upload Expert @@ -510,15 +859,19 @@

{% endif %} - -
- + Cancel + + - - Cancel -
@@ -540,6 +893,448 @@

return TAXON_ICONS[iconicTaxonName] || TAXON_ICON_DEFAULT; } +// ------------------------------------------------------- +// Multi-step wizard navigation +// ------------------------------------------------------- +const WIZARD_TOTAL_STEPS = 5; +const WIZARD_STEP_LABELS = { + 1: 'Media Upload', + 2: 'Time & Place', + 3: 'Species', + 4: 'Device & Credit', + 5: 'Preview & Publish', +}; +let currentWizardStep = 1; + +function showWizardStep(step) { + document.querySelectorAll('.wizard-step').forEach(el => { + el.classList.toggle('d-none', parseInt(el.dataset.step, 10) !== step); + }); + + document.querySelectorAll('#wizardProgress .wp-step').forEach(el => { + const n = parseInt(el.dataset.wpStep, 10); + el.classList.toggle('active', n === step); + el.classList.toggle('completed', n < step); + }); + + const caption = document.getElementById('wizardStepCaption'); + if (caption) caption.textContent = `Step ${step} of ${WIZARD_TOTAL_STEPS} — ${WIZARD_STEP_LABELS[step]}`; + + document.getElementById('wizardBackBtn').classList.toggle('d-none', step === 1); + document.getElementById('wizardNextBtn').classList.toggle('d-none', step === WIZARD_TOTAL_STEPS); + + // Only make the Publish button a real submit button while on the final + // step, so pressing Enter in an earlier step's text field can never + // trigger an implicit native form submission. + const publishBtn = document.getElementById('wizardPublishBtn'); + publishBtn.classList.toggle('d-none', step !== WIZARD_TOTAL_STEPS); + publishBtn.type = (step === WIZARD_TOTAL_STEPS) ? 'submit' : 'button'; + + if (step === WIZARD_TOTAL_STEPS) { + renderPreview(); + } + + currentWizardStep = step; + + const progressEl = document.getElementById('wizardProgress'); + if (progressEl) { + window.scrollTo({ top: Math.max(progressEl.offsetTop - 20, 0), behavior: 'smooth' }); + } +} + +function validateStep(step) { + const container = document.querySelector(`.wizard-step[data-step="${step}"]`); + if (!container) return true; + const requiredFields = container.querySelectorAll('[required]'); + for (const field of requiredFields) { + if (!field.reportValidity()) { + return false; + } + } + return true; +} + +function wizardNext() { + if (!validateStep(currentWizardStep)) return; + if (currentWizardStep < WIZARD_TOTAL_STEPS) { + showWizardStep(currentWizardStep + 1); + } +} + +function wizardBack() { + if (currentWizardStep > 1) { + showWizardStep(currentWizardStep - 1); + } +} + +function goToWizardStep(step) { + // Only allow jumping back to an already-completed step from the progress bar. + if (step < currentWizardStep) { + showWizardStep(step); + } +} + +function toggleTimestampOffset() { + const box = document.getElementById('timestamp_offset_toggle'); + document.getElementById('timestamp_offset_fields').classList.toggle('d-none', !box.checked); +} + +// ------------------------------------------------------- +// Step 5: only show Obfuscation Radius / Location Accuracy when the chosen +// Public Setting is "Approximate" — they're meaningless for Public (exact) +// or Hidden (no location shown at all). +// ------------------------------------------------------- +function updateObfuscationVisibility() { + const privacy = document.getElementById('privacy_setting').value; + document.getElementById('obfuscation_accuracy_wrap').classList.toggle('d-none', privacy !== 'obscured'); +} + +// ------------------------------------------------------- +// Step 4: saved-device toggle +// ------------------------------------------------------- +function setDeviceMode(mode) { + const savedWrap = document.getElementById('saved_device_wrap'); + const newSaveWrap = document.getElementById('new_device_save_wrap'); + const cameraModelWrap = document.getElementById('camera_model_wrap'); + const cameraModelField = document.getElementById('camera_model'); + if (mode === 'saved') { + savedWrap.classList.remove('d-none'); + newSaveWrap.classList.add('d-none'); + // The saved-device dropdown already shows the camera model — don't ask + // for it a second time. The hidden input still carries its value. + cameraModelWrap.classList.add('d-none'); + cameraModelField.readOnly = true; + } else { + savedWrap.classList.add('d-none'); + newSaveWrap.classList.remove('d-none'); + cameraModelWrap.classList.remove('d-none'); + cameraModelField.readOnly = false; + document.getElementById('saved_device_select').value = ''; + } +} + +function applySavedDevice() { + const select = document.getElementById('saved_device_select'); + const opt = select.options[select.selectedIndex]; + if (opt && opt.value) { + document.getElementById('camera_model').value = opt.dataset.cameraModel || ''; + document.getElementById('camera_deployment_date').value = opt.dataset.cameraDeploymentDate || ''; + exifOwnsCameraModel = false; + + const rememberedType = recallDeviceType(opt.dataset.cameraModel || ''); + if (rememberedType) { + maybeAutoSelectDeviceType(rememberedType); + } + } +} + +// ------------------------------------------------------- +// Remember which device type a given camera model was last tagged as, so a +// future photo from the same camera can default to it. Purely local (no +// server-side field exists for this) — a best-effort convenience only. +// ------------------------------------------------------- +const DEVICE_TYPE_MEMORY_KEY = 'wb_device_type_by_camera_model'; + +function rememberDeviceType(cameraModel, type) { + if (!cameraModel || !type) return; + try { + const map = JSON.parse(localStorage.getItem(DEVICE_TYPE_MEMORY_KEY) || '{}'); + map[cameraModel.trim().toLowerCase()] = type; + localStorage.setItem(DEVICE_TYPE_MEMORY_KEY, JSON.stringify(map)); + } catch (e) { /* localStorage unavailable — ignore */ } +} + +function recallDeviceType(cameraModel) { + if (!cameraModel) return null; + try { + const map = JSON.parse(localStorage.getItem(DEVICE_TYPE_MEMORY_KEY) || '{}'); + return map[cameraModel.trim().toLowerCase()] || null; + } catch (e) { + return null; + } +} + +// Called on every manual device-type click so we learn from deliberate +// user choices (never from our own auto-picks). +function rememberCurrentDeviceType() { + const cameraModel = document.getElementById('camera_model').value.trim(); + const checked = document.querySelector('input[name="device_type"]:checked'); + if (cameraModel && checked) { + rememberDeviceType(cameraModel, checked.value); + } +} + +// Finds an existing saved location that already has this exact camera +// model attached, so a recognized device/camera can auto-switch to +// "Use Saved Device" instead of being treated as new. +function findSavedDeviceByCameraModel(cameraModel) { + if (!cameraModel) return null; + const normalized = cameraModel.trim().toLowerCase(); + return savedLocations.find(loc => (loc.camera_model || '').trim().toLowerCase() === normalized) || null; +} + +function selectSavedDevice(loc) { + document.getElementById('device_mode_saved').checked = true; + setDeviceMode('saved'); + const select = document.getElementById('saved_device_select'); + const option = Array.from(select.options).find(o => o.value === String(loc.id)); + if (option) { + select.value = option.value; + } + document.getElementById('camera_model').value = loc.camera_model || ''; + document.getElementById('camera_deployment_date').value = loc.camera_deployment_date || ''; +} + +// ------------------------------------------------------- +// Step 5: live preview +// ------------------------------------------------------- +function renderPreview() { + const thumbWrap = document.getElementById('previewMediaThumb'); + const firstImg = document.querySelector('#mediaPreview img'); + const firstVideo = document.querySelector('#mediaPreview video'); + if (firstImg) { + thumbWrap.innerHTML = `Preview`; + } else if (firstVideo) { + thumbWrap.innerHTML = ` + Video + + `; + } else { + thumbWrap.innerHTML = ``; + } + + const speciesInputs = Array.from(document.querySelectorAll('input[name="species_list"]')) + .map(i => i.value.trim()) + .filter(Boolean); + document.getElementById('previewSpecies').textContent = speciesInputs.length ? speciesInputs.join(', ') : 'Species not specified'; + + const date = document.getElementById('encounter_date').value; + const time = document.getElementById('encounter_time').value; + document.getElementById('previewWhen').textContent = date ? `${date}${time ? ' ' + time : ''}` : 'Not specified'; + + const lat = document.getElementById('location_latitude').value; + const lng = document.getElementById('location_longitude').value; + document.getElementById('previewLocation').textContent = (lat && lng) ? `${parseFloat(lat).toFixed(5)}, ${parseFloat(lng).toFixed(5)}` : 'Not specified'; + + const camera = document.getElementById('camera_model').value; + document.getElementById('previewCamera').textContent = camera || 'Camera not specified'; + + const licenseSelect = document.getElementById('license_code'); + const licenseOpt = licenseSelect.options[licenseSelect.selectedIndex]; + document.getElementById('previewLicense').textContent = licenseOpt ? licenseOpt.text : '—'; + + const attribution = document.getElementById('attribution_override').value.trim(); + document.getElementById('previewAuthor').textContent = attribution || 'You'; +} + +// ------------------------------------------------------- +// Step 5: Obfuscation Radius / Location Accuracy unit toggle. The backend +// fields (obfuscation_kilometers, location_accuracy_meters) always store +// metric values — these are hidden inputs kept in sync by a separate, +// user-facing display field that can show km/m or mi/ft. +// ------------------------------------------------------- +const KM_PER_MILE = 1.609344; +const METERS_PER_FOOT = 0.3048; +let currentDistanceUnit = 'metric'; + +function setDistanceUnit(unit) { + currentDistanceUnit = unit; + const isImperial = unit === 'imperial'; + + const kmField = document.getElementById('obfuscation_kilometers'); + const radiusDisplay = document.getElementById('obfuscation_radius_display'); + const kmValue = parseFloat(kmField.value) || 0; + radiusDisplay.value = isImperial ? (kmValue / KM_PER_MILE).toFixed(1) : kmValue.toFixed(1); + radiusDisplay.min = isImperial ? (1 / KM_PER_MILE).toFixed(2) : 1; + radiusDisplay.max = isImperial ? (10 / KM_PER_MILE).toFixed(2) : 10; + document.getElementById('obfuscationRadiusLabel').textContent = isImperial ? 'Obfuscation Radius (mi)' : 'Obfuscation Radius (km)'; + + refreshAccuracyDisplay(); + document.getElementById('locationAccuracyLabel').textContent = isImperial ? 'Location Accuracy (ft)' : 'Location Accuracy (m)'; +} + +// Location Accuracy is a readonly, auto-detected value (from "Use Current +// Location") — this just mirrors it into the currently selected display +// unit whenever the underlying meters value changes. +function refreshAccuracyDisplay() { + const isImperial = currentDistanceUnit === 'imperial'; + const metersField = document.getElementById('location_accuracy_meters'); + const accuracyDisplay = document.getElementById('location_accuracy_display'); + const metersValue = parseFloat(metersField.value) || 0; + accuracyDisplay.value = isImperial ? Math.round(metersValue / METERS_PER_FOOT) : Math.round(metersValue); +} + +// The radius display field is user-editable — convert whatever's typed back +// into km for the hidden field that actually gets submitted. +function onObfuscationDisplayChange() { + const display = document.getElementById('obfuscation_radius_display'); + const kmField = document.getElementById('obfuscation_kilometers'); + const value = parseFloat(display.value) || 0; + kmField.value = (currentDistanceUnit === 'imperial' ? value * KM_PER_MILE : value).toFixed(3); +} + +// ------------------------------------------------------- +// Step 3: "not sure of species" toggle +// ------------------------------------------------------- +function toggleSpeciesUnsure() { + const unsure = document.getElementById('species_unsure_toggle').checked; + document.getElementById('species-container').classList.toggle('d-none', unsure); + document.getElementById('species-add-row').classList.toggle('d-none', unsure); + if (unsure) { + // Clear any partially entered species so nothing stale gets submitted + document.querySelectorAll('input[name="species_list"]').forEach(i => { i.value = ''; }); + } +} + +// ------------------------------------------------------- +// Step 4: device type toggle — Camera Deployment Date only makes sense for +// installed/deployed devices (Home Camera, Trail Camera). Device Source +// (new vs. saved) and Camera Model apply to all four device types. +// ------------------------------------------------------- +function setDeviceType(type) { + const showInstalledFields = (type === 'home_camera' || type === 'trail_camera'); + document.getElementById('installed_device_fields').classList.toggle('d-none', !showInstalledFields); +} + +// Auto-picks a device type from EXIF (e.g. Apple's Make tag -> "Phone"), +// but only if the user hasn't already made their own choice — a prior +// auto-pick (exifOwnsDeviceType) is fine to override, a deliberate click isn't. +function maybeAutoSelectDeviceType(type) { + const anySelected = document.querySelector('input[name="device_type"]:checked'); + if (anySelected && !exifOwnsDeviceType) return; + const radio = document.getElementById(`device_type_${type}`); + if (!radio) return; + radio.checked = true; + setDeviceType(type); + exifOwnsDeviceType = true; +} + +// ------------------------------------------------------- +// Step 2: "Saved Location" vs "New Location" toggle +// ------------------------------------------------------- +// Once the user has clicked either button, their choice sticks — the +// "no saved locations yet" default below only applies before that. +let locationModeUserChosen = false; + +function setLocationMode(mode) { + const isSaved = mode === 'saved'; + document.getElementById('saved_location_wrap').classList.toggle('d-none', !isSaved); + document.getElementById('new_location_wrap').classList.toggle('d-none', isSaved); +} + +// If "New Location" is selected and the user checked "Save this location +// for next time", persist it via the existing saved-locations API before +// the sighting itself is submitted. +async function maybeSaveNewLocation() { + const newModeRadio = document.getElementById('location_mode_new'); + if (!newModeRadio.checked) return; + + const saveCheckbox = document.getElementById('save_new_location'); + if (!saveCheckbox || !saveCheckbox.checked) return; + + const lat = parseFloat(document.getElementById('location_latitude').value); + const lng = parseFloat(document.getElementById('location_longitude').value); + if (isNaN(lat) || isNaN(lng)) return; + + const name = document.getElementById('new_location_name').value.trim() + || `Saved Location ${new Date().toLocaleDateString()}`; + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; + + try { + await fetch('/sightings/api/backend/locations/create/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + }, + body: JSON.stringify({ + name: name, + description: null, + latitude: lat, + longitude: lng, + camera_model: null, + camera_serial_number: null, + camera_deployment_date: null, + camera_timestamp_offset_error_details: null, + }), + }); + } catch (e) { + // Non-critical — proceed with the sighting submission even if saving the location failed. + } +} + +// If "Add New Device" is selected (any of the four device types) and the +// user checked "Save this device for next time", persist it the same way — +// camera devices are just saved locations with camera fields attached, +// there's no separate "device" concept on the backend. +async function maybeSaveNewDevice() { + const newModeRadio = document.getElementById('device_mode_new'); + if (!newModeRadio.checked) return; + + const saveCheckbox = document.getElementById('save_new_device'); + if (!saveCheckbox || !saveCheckbox.checked) return; + + const cameraModel = document.getElementById('camera_model').value.trim(); + if (!cameraModel) return; + + const lat = parseFloat(document.getElementById('location_latitude').value); + const lng = parseFloat(document.getElementById('location_longitude').value); + if (isNaN(lat) || isNaN(lng)) return; + + const name = document.getElementById('new_device_name').value.trim() || cameraModel; + const deploymentDate = document.getElementById('camera_deployment_date').value || null; + const deviceTypeRadio = document.querySelector('input[name="device_type"]:checked'); + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; + + try { + await fetch('/sightings/api/backend/locations/create/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + }, + body: JSON.stringify({ + name: name, + description: null, + latitude: lat, + longitude: lng, + camera_model: cameraModel, + camera_serial_number: null, + camera_deployment_date: deploymentDate, + camera_timestamp_offset_error_details: null, + }), + }); + } catch (e) { + // Non-critical — proceed with the sighting submission even if saving the device failed. + } + + if (deviceTypeRadio) { + rememberDeviceType(cameraModel, deviceTypeRadio.value); + } +} + +// Final safety net: even though Next-button validation should catch this, +// required fields living in a d-none ancestor are exempt from native +// constraint validation, so re-check everything before the real submit. +document.getElementById('sightingForm').addEventListener('submit', async function(e) { + e.preventDefault(); + + const requiredFields = Array.from(document.querySelectorAll('#sightingForm [required]')); + for (const field of requiredFields) { + if (!field.value || !field.value.toString().trim()) { + const stepEl = field.closest('.wizard-step'); + const step = stepEl ? parseInt(stepEl.dataset.step, 10) : null; + if (step) showWizardStep(step); + field.focus(); + return; + } + } + + await maybeSaveNewLocation(); + await maybeSaveNewDevice(); + this.submit(); +}); + // ------------------------------------------------------- // Multi-species typeahead (up to 5) // ------------------------------------------------------- @@ -680,8 +1475,9 @@

map.addControl(new maplibregl.NavigationControl()); - // Set today's date as default - document.getElementById('encounter_date').valueAsDate = new Date(); + // Date/time starts blank and editable — it only gets set (and locked) once + // a photo/video's own date is extracted; see extractExifGPS/setDateLocked. + setDateLocked(false); // Initialize first species row addSpeciesRow(); @@ -694,6 +1490,11 @@

document.getElementById('post_body').addEventListener('input', function() { document.getElementById('bodyCount').textContent = this.value.length; }); + + updateObfuscationVisibility(); + + // Kick off the wizard on step 1 + showWizardStep(1); }); function getCurrentLocation() { @@ -706,6 +1507,8 @@

document.getElementById('location_latitude').value = lat.toFixed(6); document.getElementById('location_longitude').value = lng.toFixed(6); document.getElementById('location_accuracy_meters').value = accuracy; + refreshAccuracyDisplay(); + exifOwnsLocation = false; updateMapMarker(lng, lat); map.flyTo({center: [lng, lat], zoom: 14}); @@ -724,6 +1527,7 @@

document.getElementById('location_latitude').value = lat.toFixed(6); document.getElementById('location_longitude').value = lng.toFixed(6); + exifOwnsLocation = false; updateMapMarker(lng, lat); }); @@ -740,70 +1544,172 @@

const now = new Date(); document.getElementById('encounter_date').valueAsDate = now; document.getElementById('encounter_time').value = now.toTimeString().slice(0, 5); + exifOwnsDate = false; +} + +// Source of truth for staged media files — kept in sync with #media_files +// via DataTransfer so individual files can be removed after selection. +let selectedMediaFiles = []; + +// Whether camera_model / lat+lng currently hold values auto-filled from a +// photo's EXIF data (vs. typed/picked by the user or loaded from a saved +// location/device). Lets us safely clear stale EXIF values when the source +// file is removed or replaced, without ever touching a user's own input. +let exifOwnsCameraModel = false; +let exifOwnsLocation = false; +let exifOwnsDeviceType = false; +let exifOwnsDate = false; + +// When a photo/video's own date+time was found, the date/time fields become +// unchangeable (the Timestamp Offset checkbox is how the user corrects for +// a wrong camera clock instead) and "Use Current Date/Time" is hidden, since +// it wouldn't make sense to offer "now" as an alternative to a known, +// extracted capture time. With nothing detected, fields stay blank/editable +// and the button stays available. +function setDateLocked(locked) { + const dateField = document.getElementById('encounter_date'); + const timeField = document.getElementById('encounter_time'); + const btn = document.getElementById('useCurrentDateTimeBtn'); + const hint = document.getElementById('dateLockedHint'); + dateField.readOnly = locked; + timeField.readOnly = locked; + dateField.classList.toggle('date-field-locked', locked); + timeField.classList.toggle('date-field-locked', locked); + if (btn) btn.classList.toggle('d-none', locked); + if (hint) hint.classList.toggle('d-none', !locked); } function previewMultipleMedia(input) { - const preview = document.getElementById('mediaPreview'); - preview.innerHTML = ''; + if (!input.files || input.files.length === 0) { + selectedMediaFiles = []; + renderMediaPreviews(); + return; + } + + const maxSize = 500 * 1024 * 1024; // 500MB per file - if (input.files && input.files.length > 0) { - const maxSize = 500 * 1024 * 1024; // 500MB per file + // Validate number of files (use dynamically loaded limit) + if (input.files.length > maxSightingImages) { + alert(`You can only upload up to ${maxSightingImages} files at once.`); + input.value = ''; + selectedMediaFiles = []; + renderMediaPreviews(); + return; + } - // Validate number of files (use dynamically loaded limit) - if (input.files.length > maxSightingImages) { - alert(`You can only upload up to ${maxSightingImages} files at once.`); + // Validate file sizes up front so a bad file doesn't leave a half-built preview + for (const file of input.files) { + if (file.size > maxSize) { + alert(`File "${file.name}" is too large. Maximum size is 500MB.`); input.value = ''; + selectedMediaFiles = []; + renderMediaPreviews(); return; } + } - // Process each file - Array.from(input.files).forEach((file, index) => { - // Validate file size - if (file.size > maxSize) { - alert(`File "${file.name}" is too large. Maximum size is 500MB.`); - input.value = ''; - return; - } + selectedMediaFiles = Array.from(input.files); + renderMediaPreviews(); +} - const reader = new FileReader(); - const colDiv = document.createElement('div'); - colDiv.className = 'col-md-4 col-sm-6'; - colDiv.id = `preview-${index}`; - - reader.onload = function(e) { - let mediaHtml = ''; - if (file.type.startsWith('image/')) { - mediaHtml = ` -
- Preview ${index + 1} - ${index + 1} -
- ${file.name} - `; +function renderMediaPreviews() { + const preview = document.getElementById('mediaPreview'); + preview.innerHTML = ''; - // Extract EXIF from first image only - if (index === 0) { - extractExifGPS(e.target.result); - } - } else if (file.type.startsWith('video/')) { - const sizeInfo = `${(file.size / (1024*1024)).toFixed(2)}MB`; - mediaHtml = ` -
- - ${index + 1} -
- ${file.name} (${sizeInfo}) - `; + // The staged file selection just changed (new pick, or a removal) — clear + // any camera/location values that came from a previous file's EXIF data + // so stale metadata can't linger after that file is gone. Re-extraction + // below will repopulate them from whichever file is now first, if any. + if (exifOwnsCameraModel) { + document.getElementById('camera_model').value = ''; + // Also undo a possible auto-selected saved device from the removed file. + document.getElementById('device_mode_new').checked = true; + setDeviceMode('new'); + exifOwnsCameraModel = false; + } + if (exifOwnsLocation) { + document.getElementById('location_latitude').value = ''; + document.getElementById('location_longitude').value = ''; + exifOwnsLocation = false; + if (typeof marker !== 'undefined' && marker) { + marker.remove(); + marker = null; + } + } + if (exifOwnsDeviceType) { + document.querySelectorAll('input[name="device_type"]').forEach(r => { r.checked = false; }); + document.getElementById('installed_device_fields').classList.add('d-none'); + exifOwnsDeviceType = false; + } + if (exifOwnsDate) { + document.getElementById('encounter_date').value = ''; + document.getElementById('encounter_time').value = ''; + setDateLocked(false); + exifOwnsDate = false; + } + document.getElementById('exifSummary').classList.add('d-none'); + + selectedMediaFiles.forEach((file, index) => { + const reader = new FileReader(); + const colDiv = document.createElement('div'); + colDiv.className = 'col-md-4 col-sm-6'; + colDiv.id = `preview-${index}`; + + reader.onload = function(e) { + let mediaHtml = ''; + if (file.type.startsWith('image/')) { + mediaHtml = ` +
+ Preview ${index + 1} + ${index + 1} + +
+ ${file.name} + `; + + // Extract EXIF from first image only + if (index === 0) { + extractExifGPS(e.target.result); } - colDiv.innerHTML = mediaHtml; - }; + } else if (file.type.startsWith('video/')) { + const sizeInfo = `${(file.size / (1024*1024)).toFixed(2)}MB`; + mediaHtml = ` +
+ + ${index + 1} + +
+ ${file.name} (${sizeInfo}) + `; + } + colDiv.innerHTML = mediaHtml; + }; - preview.appendChild(colDiv); - reader.readAsDataURL(file); - }); + preview.appendChild(colDiv); + reader.readAsDataURL(file); + }); +} + +// Removes one staged file (e.g. picked by mistake) and rebuilds the real +// #media_files input via DataTransfer so the removal is reflected on submit. +function removeMediaFile(index) { + selectedMediaFiles.splice(index, 1); + + const input = document.getElementById('media_files'); + const dt = new DataTransfer(); + selectedMediaFiles.forEach(file => dt.items.add(file)); + input.files = dt.files; + if (selectedMediaFiles.length === 0) { + input.value = ''; } + + renderMediaPreviews(); } // Legacy function for backwards compatibility @@ -825,6 +1731,11 @@

img.onload = function() { try { EXIF.getData(img, function() { + // What we found, for the "detected from your photo" summary panel — + // shown regardless of whether each piece could auto-fill a field, so + // the user always sees what was actually read off the file itself. + const summary = { camera: null, dateTime: null, gpsText: null, gpsFound: false, recognizedAsSaved: false }; + const lat = EXIF.getTag(this, 'GPSLatitude'); const latRef = EXIF.getTag(this, 'GPSLatitudeRef'); const lng = EXIF.getTag(this, 'GPSLongitude'); @@ -834,18 +1745,71 @@

const make = (EXIF.getTag(this, 'Make') || '').replace(/\0/g, '').trim() || null; const model = (EXIF.getTag(this, 'Model') || '').replace(/\0/g, '').trim() || null; if (make || model) { + // Combine make + model, avoiding duplicate words. Many cameras + // (e.g. Nikon: Make="NIKON CORPORATION", Model="NIKON D3500") + // repeat only the brand's first word, not the full make string, + // so a plain startsWith() check doesn't catch the overlap. + let cameraString = ''; + const makeStr = (make || '').trim(); + const modelStr = (model || '').trim(); + if (makeStr && modelStr) { + const makeFirstWord = makeStr.split(/\s+/)[0].toLowerCase(); + cameraString = modelStr.toLowerCase().includes(makeFirstWord) + ? modelStr + : `${makeStr} ${modelStr}`; + } else { + cameraString = modelStr || makeStr; + } + summary.camera = cameraString; + const cameraModelField = document.getElementById('camera_model'); if (cameraModelField && cameraModelField.value === '') { - // Combine make + model, avoiding duplicate words (e.g. "Canon Canon EOS 5D") - let cameraString = ''; - const makeStr = (make || '').trim(); - const modelStr = (model || '').trim(); - if (makeStr && modelStr && !modelStr.toLowerCase().startsWith(makeStr.toLowerCase())) { - cameraString = `${makeStr} ${modelStr}`; + // Recognize this exact camera as an already-saved device first — + // if found, switch straight to "Use Saved Device" instead of + // treating it as new. + const matchedDevice = findSavedDeviceByCameraModel(cameraString); + if (matchedDevice) { + selectSavedDevice(matchedDevice); + summary.recognizedAsSaved = true; } else { - cameraString = modelStr || makeStr; + cameraModelField.value = cameraString; + const newDeviceNameField = document.getElementById('new_device_name'); + if (newDeviceNameField && newDeviceNameField.value === '') { + newDeviceNameField.value = cameraString; + } + } + exifOwnsCameraModel = true; + + // Recall a device type we've learned for this camera before; + // fall back to the Apple -> Phone heuristic if we haven't. + const rememberedType = recallDeviceType(cameraString); + if (rememberedType) { + maybeAutoSelectDeviceType(rememberedType); + } else if (make && make.trim().toLowerCase() === 'apple') { + maybeAutoSelectDeviceType('phone'); } - cameraModelField.value = cameraString; + } + } + + // Extract when the photo was actually taken. If found, the date/time + // fields get locked to it (see setDateLocked) rather than left open + // for editing — a wrong camera clock is corrected via the Timestamp + // Offset option instead of overwriting the extracted value. + const rawDate = EXIF.getTag(this, 'DateTimeOriginal') + || EXIF.getTag(this, 'DateTimeDigitized') + || EXIF.getTag(this, 'DateTime'); + if (rawDate && rawDate.length >= 19) { + const isoDate = rawDate.substring(0, 10).replace(/:/g, '-'); + const isoTime = rawDate.substring(11, 16); + summary.dateTime = `${isoDate} ${isoTime}`; + + const dateField = document.getElementById('encounter_date'); + const timeField = document.getElementById('encounter_time'); + if (dateField && timeField && dateField.value === '') { + dateField.value = isoDate; + timeField.value = isoTime; + exifOwnsDate = true; + setDateLocked(true); } } @@ -859,50 +1823,69 @@

latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { console.warn('Invalid GPS coordinates extracted from EXIF:', {latitude, longitude}); - return; - } + } else { + summary.gpsFound = true; + summary.gpsText = `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`; - // Only populate if fields are empty - const latField = document.getElementById('location_latitude'); - const lngField = document.getElementById('location_longitude'); + // Only populate if fields are empty + const latField = document.getElementById('location_latitude'); + const lngField = document.getElementById('location_longitude'); - if (latField.value === '') { - latField.value = latitude.toFixed(6); - } - if (lngField.value === '') { - lngField.value = longitude.toFixed(6); - } + if (latField.value === '') { + latField.value = latitude.toFixed(6); + exifOwnsLocation = true; + } + if (lngField.value === '') { + lngField.value = longitude.toFixed(6); + exifOwnsLocation = true; + } - // Update map with EXIF location (only if map is initialized) - if (typeof map !== 'undefined' && map) { - updateMapMarker(longitude, latitude); - map.flyTo({center: [longitude, latitude], zoom: 14}); + // Update map with EXIF location (only if map is initialized) + if (typeof map !== 'undefined' && map) { + updateMapMarker(longitude, latitude); + map.flyTo({center: [longitude, latitude], zoom: 14}); + } } - - // Show notification - const preview = document.getElementById('mediaPreview'); - const notification = document.createElement('div'); - notification.className = 'alert alert-success mt-2'; - - const icon = document.createElement('i'); - icon.className = 'bi bi-check-circle'; - notification.appendChild(icon); - - const message = document.createTextNode(' GPS coordinates extracted from image EXIF data'); - notification.appendChild(message); - - preview.appendChild(notification); - - // Remove notification after 5 seconds - setTimeout(() => notification.remove(), 5000); } + + showExifSummary(summary); }); } catch (error) { - console.error('Error extracting EXIF GPS data:', error); + console.error('Error extracting EXIF data:', error); } }; } +// Persistent (not auto-dismissing) summary of whatever was actually read +// from the photo's EXIF data, shown right under the media preview so the +// user can see at a glance what was auto-filled without hunting through +// later steps — the whole point being to cut down on manual re-entry. +function showExifSummary(summary) { + const box = document.getElementById('exifSummary'); + if (!box) return; + + const cameraLine = summary.camera + ? `${summary.camera}${summary.recognizedAsSaved ? ' matched saved device' : ''}` + : `Camera not detected — you can enter it in the Device step`; + + const dateLine = summary.dateTime + ? `${summary.dateTime}` + : `Date/time not detected — you can enter it in the Time & Place step`; + + const gpsLine = summary.gpsFound + ? `${summary.gpsText}` + : `We couldn't find a location tag in this file. No worries; you can easily pin it on the map in the next step!`; + + box.className = `alert ${summary.gpsFound ? 'alert-success' : 'alert-info'} mt-2 small mb-0`; + box.innerHTML = ` +
Media info detected
+
${cameraLine}
+
${dateLine}
+
${gpsLine}
+ `; + box.classList.remove('d-none'); +} + function convertDMSToDD(dms, ref) { // DMS is typically [degrees, minutes, seconds] // EXIF.js returns these as rational numbers (with numerator/denominator) or plain numbers @@ -959,6 +1942,18 @@

} }); +// A manual keystroke means the user now owns this value — don't let a later +// file removal clear it out from under them. +document.getElementById('location_latitude').addEventListener('input', function() { exifOwnsLocation = false; }); +document.getElementById('location_longitude').addEventListener('input', function() { exifOwnsLocation = false; }); +document.getElementById('camera_model').addEventListener('input', function() { exifOwnsCameraModel = false; }); +// Readonly normally blocks this, but some browsers still let the native +// date-picker icon change a "readonly" date input — if that happens, treat +// it as a deliberate override and fully unlock rather than leaving things +// in a half-locked state. +document.getElementById('encounter_date').addEventListener('input', function() { exifOwnsDate = false; setDateLocked(false); }); +document.getElementById('encounter_time').addEventListener('input', function() { exifOwnsDate = false; setDateLocked(false); }); + // Global variable to store saved locations let savedLocations = []; @@ -1069,6 +2064,36 @@

option.dataset.cameraTimestampOffset = loc.camera_timestamp_offset_error_details || ''; select.appendChild(option); }); + + // Also populate the Step 4 "saved device" dropdown with any saved + // location that has a camera model attached. + const deviceSelect = document.getElementById('saved_device_select'); + if (deviceSelect) { + while (deviceSelect.options.length > 1) { + deviceSelect.remove(1); + } + savedLocations.filter(loc => loc.camera_model).forEach(loc => { + const opt = document.createElement('option'); + opt.value = loc.id; + opt.textContent = loc.name ? `${loc.camera_model} — ${loc.name}` : loc.camera_model; + opt.dataset.cameraModel = loc.camera_model || ''; + opt.dataset.cameraDeploymentDate = loc.camera_deployment_date || ''; + deviceSelect.appendChild(opt); + }); + } + + // Default to whichever tab makes sense for this account: straight to + // "New Location" if nothing's saved yet, otherwise "Saved Location" — + // but never override a choice the user already made themselves. + if (!locationModeUserChosen) { + if (savedLocations.length === 0) { + document.getElementById('location_mode_new').checked = true; + setLocationMode('new'); + } else { + document.getElementById('location_mode_saved').checked = true; + setLocationMode('saved'); + } + } } } catch (error) { console.error('Error loading saved locations:', error); @@ -1086,6 +2111,7 @@

document.getElementById('location_latitude').value = lat; document.getElementById('location_longitude').value = lng; + exifOwnsLocation = false; updateMapMarker(lng, lat); map.flyTo({center: [lng, lat], zoom: 14}); @@ -1094,6 +2120,7 @@

const cameraModelField = document.getElementById('camera_model'); if (selectedOption.dataset.cameraModel !== undefined) { cameraModelField.value = selectedOption.dataset.cameraModel; + exifOwnsCameraModel = false; } const deploymentField = document.getElementById('camera_deployment_date'); if (selectedOption.dataset.cameraDeploymentDate !== undefined) { @@ -1257,7 +2284,19 @@

const model = (EXIF.getTag(this, 'Model') || '').replace(/\0/g, '').trim(); // Filter out placeholder strings that indicate no real camera info const _isUnknown = (s) => !s || /^(unknown|n\/a|none|-|na)$/i.test(s.trim()); - const cameraModel = [make, model].filter(s => s && !_isUnknown(s)).join(' ').trim() || null; + const validMake = make && !_isUnknown(make) ? make : ''; + const validModel = model && !_isUnknown(model) ? model : ''; + // Avoid duplicating the brand name (e.g. Nikon: Make="NIKON CORPORATION", + // Model="NIKON D3500" — only the first word overlaps, not the full make). + let cameraModel = null; + if (validMake && validModel) { + const makeFirstWord = validMake.split(/\s+/)[0].toLowerCase(); + cameraModel = validModel.toLowerCase().includes(makeFirstWord) + ? validModel + : `${validMake} ${validModel}`; + } else { + cameraModel = validModel || validMake || null; + } // Extract GPS coordinates let lat = null; From 67dfe67373585f13602bc64ab4c2e1070d97e33c Mon Sep 17 00:00:00 2001 From: irenecancode <196759302+irenecancode@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:30:04 -0700 Subject: [PATCH 2/4] copy: polish empty state messages to be warm, punchy, and device-agnostic --- .../templates/sightings/create_sighting.html | 84 ++++++++++++------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/siteapps/templates/sightings/create_sighting.html b/siteapps/templates/sightings/create_sighting.html index 0b9dd97..1bc4b5f 100644 --- a/siteapps/templates/sightings/create_sighting.html +++ b/siteapps/templates/sightings/create_sighting.html @@ -1551,6 +1551,15 @@

// via DataTransfer so individual files can be removed after selection. let selectedMediaFiles = []; +// Bumped every time the staged selection changes (add or remove). EXIF +// extraction is asynchronous (FileReader -> Image.onload -> EXIF.getData), +// so a slow extraction for a photo the user has since deleted/replaced can +// otherwise resolve *after* a newer, faster extraction (or a delete) has +// already run — silently reapplying stale camera/location/date data. Each +// extraction call is stamped with the generation it started in and checks +// it's still current before writing anything. +let mediaGeneration = 0; + // Whether camera_model / lat+lng currently hold values auto-filled from a // photo's EXIF data (vs. typed/picked by the user or loaded from a saved // location/device). Lets us safely clear stale EXIF values when the source @@ -1579,40 +1588,55 @@

if (hint) hint.classList.toggle('d-none', !locked); } +// Rebuilds the real #media_files input's FileList from selectedMediaFiles — +// browsers only let you replace .files wholesale (via DataTransfer), not +// append to it, so every mutation of the staged list goes through here. +function syncMediaInput() { + const input = document.getElementById('media_files'); + const dt = new DataTransfer(); + selectedMediaFiles.forEach(file => dt.items.add(file)); + input.files = dt.files; + if (selectedMediaFiles.length === 0) { + input.value = ''; + } +} + function previewMultipleMedia(input) { if (!input.files || input.files.length === 0) { - selectedMediaFiles = []; - renderMediaPreviews(); + // Dialog was cancelled — leave whatever's already staged untouched. return; } const maxSize = 500 * 1024 * 1024; // 500MB per file + const newFiles = Array.from(input.files); - // Validate number of files (use dynamically loaded limit) - if (input.files.length > maxSightingImages) { - alert(`You can only upload up to ${maxSightingImages} files at once.`); - input.value = ''; - selectedMediaFiles = []; - renderMediaPreviews(); - return; - } - - // Validate file sizes up front so a bad file doesn't leave a half-built preview - for (const file of input.files) { + // Validate sizes up front so a bad file never gets merged in. + for (const file of newFiles) { if (file.size > maxSize) { alert(`File "${file.name}" is too large. Maximum size is 500MB.`); - input.value = ''; - selectedMediaFiles = []; - renderMediaPreviews(); + syncMediaInput(); // restore the input to the still-valid selection return; } } - selectedMediaFiles = Array.from(input.files); + // Opening the file picker again only ever hands back the files chosen in + // that dialog — merge them with whatever's already staged (instead of + // replacing it) so picking photos one at a time actually accumulates + // toward the limit, rather than each pick replacing the last. + const room = Math.max(maxSightingImages - selectedMediaFiles.length, 0); + if (newFiles.length > room) { + alert(`You can upload up to ${maxSightingImages} files total.${selectedMediaFiles.length ? ` You already have ${selectedMediaFiles.length}, so only ${room} more can be added right now.` : ''}`); + } + selectedMediaFiles = selectedMediaFiles.concat(newFiles.slice(0, room)); + + syncMediaInput(); renderMediaPreviews(); } function renderMediaPreviews() { + mediaGeneration++; + const thisGeneration = mediaGeneration; + const preview = document.getElementById('mediaPreview'); preview.innerHTML = ''; @@ -1671,7 +1695,7 @@

// Extract EXIF from first image only if (index === 0) { - extractExifGPS(e.target.result); + extractExifGPS(e.target.result, thisGeneration); } } else if (file.type.startsWith('video/')) { const sizeInfo = `${(file.size / (1024*1024)).toFixed(2)}MB`; @@ -1700,15 +1724,7 @@

// #media_files input via DataTransfer so the removal is reflected on submit. function removeMediaFile(index) { selectedMediaFiles.splice(index, 1); - - const input = document.getElementById('media_files'); - const dt = new DataTransfer(); - selectedMediaFiles.forEach(file => dt.items.add(file)); - input.files = dt.files; - if (selectedMediaFiles.length === 0) { - input.value = ''; - } - + syncMediaInput(); renderMediaPreviews(); } @@ -1717,7 +1733,7 @@

previewMultipleMedia(input); } -function extractExifGPS(dataUrl) { +function extractExifGPS(dataUrl, generation) { // Check if EXIF library is loaded if (typeof EXIF === 'undefined') { console.warn('EXIF library not loaded, skipping GPS extraction'); @@ -1731,6 +1747,12 @@

img.onload = function() { try { EXIF.getData(img, function() { + // The staged selection may have changed (file removed/replaced) + // while this async extraction was still in flight — if so, this + // result is for a file that's no longer relevant, so bail out + // rather than writing stale data over whatever's current now. + if (generation !== mediaGeneration) return; + // What we found, for the "detected from your photo" summary panel — // shown regardless of whether each piece could auto-fill a field, so // the user always sees what was actually read off the file itself. @@ -1866,15 +1888,15 @@

const cameraLine = summary.camera ? `${summary.camera}${summary.recognizedAsSaved ? ' matched saved device' : ''}` - : `Camera not detected — you can enter it in the Device step`; + : `No device info found. You can manually add it later.`; const dateLine = summary.dateTime ? `${summary.dateTime}` - : `Date/time not detected — you can enter it in the Time & Place step`; + : `Missing date and time? No problem, just set them in the next step.`; const gpsLine = summary.gpsFound ? `${summary.gpsText}` - : `We couldn't find a location tag in this file. No worries; you can easily pin it on the map in the next step!`; + : `We couldn't find a location tag. Feel free to pin it on the map next!`; box.className = `alert ${summary.gpsFound ? 'alert-success' : 'alert-info'} mt-2 small mb-0`; box.innerHTML = ` From 4f6edaf31e1f9de4582d1c6f5fde783450b1acb0 Mon Sep 17 00:00:00 2001 From: irenecancode <196759302+irenecancode@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:01:12 -0700 Subject: [PATCH 3/4] Forward device_type to backend on sighting submission Passthrough for the new MediaPost.device_type field: the single-sighting form already renders device-type radios (name="device_type"); forward the selected value as deviceType so the backend can persist it. Pairs with WildeBackyardBackend: Add device_type to MediaPost. Co-Authored-By: Claude Opus 4.8 --- siteapps/sightings/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/siteapps/sightings/views.py b/siteapps/sightings/views.py index 6b64dd3..f6f034b 100644 --- a/siteapps/sightings/views.py +++ b/siteapps/sightings/views.py @@ -139,6 +139,8 @@ def post(self, request): data["obfuscationKilometers"] = obfuscation_km elif data.get("privacySetting") == "obscured": data["obfuscationKilometers"] = 2 # Default 2km when obscured and not explicitly set + if request.POST.get("device_type"): + data["deviceType"] = request.POST.get("device_type") if request.POST.get("camera_model"): data["cameraModel"] = request.POST.get("camera_model") if request.POST.get("camera_deployment_date"): From 2c84a68f9416835a3c756bd7d0851ad80a95faa4 Mon Sep 17 00:00:00 2001 From: irenecancode <196759302+irenecancode@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:42:43 -0700 Subject: [PATCH 4/4] Update privacy policy checkbox for submission form --- siteapps/sightings/tests.py | 43 ++++ siteapps/sightings/views.py | 6 + .../components/privacy_policy_content.html | 207 ++++++++++++++++++ .../templates/sightings/create_sighting.html | 98 ++++++++- 4 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 siteapps/templates/components/privacy_policy_content.html diff --git a/siteapps/sightings/tests.py b/siteapps/sightings/tests.py index ab1f627..917dc31 100644 --- a/siteapps/sightings/tests.py +++ b/siteapps/sightings/tests.py @@ -93,6 +93,7 @@ def test_post_missing_title_shows_error(self, mock_geocode, mock_client_class): "encounter_date": "2024-01-01", "location_latitude": "45.0", "location_longitude": "-93.0", + "privacy_accepted": "1", }, ) msgs = [str(m) for m in get_messages(response.wsgi_request)] @@ -113,6 +114,7 @@ def test_post_missing_datetime_shows_error(self, mock_geocode, mock_client_class "post_title": "Bird sighting", "location_latitude": "45.0", "location_longitude": "-93.0", + "privacy_accepted": "1", }, ) msgs = [str(m) for m in get_messages(response.wsgi_request)] @@ -132,6 +134,7 @@ def test_post_missing_location_shows_error(self, mock_geocode, mock_client_class { "post_title": "Bird", "encounter_date": "2024-01-01", + "privacy_accepted": "1", }, ) msgs = [str(m) for m in get_messages(response.wsgi_request)] @@ -163,6 +166,7 @@ def test_successful_submission_redirects_to_feed(self, mock_geocode, mock_client "privacy_setting": "public", "location_accuracy_meters": "5", "species_list": ["Robin"], + "privacy_accepted": "1", }, ) self.assertRedirects(response, reverse("socialmedia:feed"), fetch_redirect_response=False) @@ -184,6 +188,7 @@ def test_api_submission_failure_shows_error(self, mock_geocode, mock_client_clas "encounter_date": "2024-01-01", "location_latitude": "45.5", "location_longitude": "-122.7", + "privacy_accepted": "1", }, ) msgs = [str(m) for m in get_messages(response.wsgi_request)] @@ -205,6 +210,7 @@ def test_invalid_coordinates_shows_error(self, mock_geocode, mock_client_class): "encounter_date": "2024-01-01", "location_latitude": "not_a_number", "location_longitude": "-93.0", + "privacy_accepted": "1", }, ) msgs = [str(m) for m in get_messages(response.wsgi_request)] @@ -212,6 +218,43 @@ def test_invalid_coordinates_shows_error(self, mock_geocode, mock_client_class): any("latitude" in m.lower() or "invalid" in m.lower() or "longitude" in m.lower() for m in msgs) ) + @patch("siteapps.sightings.views.BackendAPIClient") + @patch("siteapps.sightings.views.reverse_geocode_with_nominatim") + def test_post_without_privacy_consent_is_rejected(self, mock_geocode, mock_client_class): + self._login_with_token() + mock_geocode.return_value = None + mock_api = MagicMock() + mock_api.get.return_value = {"species_names": []} + mock_client_class.return_value = mock_api + + response = self.client.post( + self.url, + { + "post_title": "Bird sighting", + "encounter_date": "2024-01-01", + "encounter_time": "10:00", + "location_latitude": "45.5", + "location_longitude": "-122.7", + "privacy_setting": "public", + }, + ) + + self.assertEqual(response.status_code, 200) + mock_api.post.assert_not_called() + msgs = [str(m) for m in get_messages(response.wsgi_request)] + self.assertTrue(any("privacy policy" in m.lower() for m in msgs)) + + def test_form_renders_privacy_consent_checkbox(self): + self._login_with_token() + response = self.client.get(self.url) + self.assertContains(response, 'name="privacy_accepted"') + self.assertContains(response, "privacyPolicyModal") + # The policy text itself is inlined in the modal, not just linked. + self.assertContains(response, "1. Information We Collect") + self.assertContains(response, "Last Updated:") + # A template placeholder must never reach a consent-gated document. + self.assertNotContains(response, "[Insert Contact Email]") + class MySightingsViewTests(TestCase): def setUp(self): diff --git a/siteapps/sightings/views.py b/siteapps/sightings/views.py index f6f034b..b43dabd 100644 --- a/siteapps/sightings/views.py +++ b/siteapps/sightings/views.py @@ -67,6 +67,12 @@ def post(self, request): messages.error(request, "Authentication required.") return redirect("users:login") + # Consent is enforced here as well as in the form, so a direct POST + # cannot skip it. + if not request.POST.get("privacy_accepted"): + messages.error(request, "You must accept the Privacy Policy before publishing a sighting.") + return self.get(request) + # Extract form data and transform to camelCase format for backend API encounter_date = request.POST.get("encounter_date") encounter_time = request.POST.get("encounter_time", "12:00") diff --git a/siteapps/templates/components/privacy_policy_content.html b/siteapps/templates/components/privacy_policy_content.html new file mode 100644 index 0000000..e3373bb --- /dev/null +++ b/siteapps/templates/components/privacy_policy_content.html @@ -0,0 +1,207 @@ +{% comment %} +WildeBackyard privacy policy body — text only, no page chrome and no colour +overrides, so it renders correctly inside a modal or a standalone page. + +Source: "Privacy Policy for WildeBackyard", effective 06/29/2026, last updated +07/27/2026. Note that wildepod.org (backyard/privacy.html) still serves the +superseded 07/04/2024 policy and needs the same update. +{% endcomment %} +

+ Effective Date: 06/29/2026 +
+ Last Updated: 07/27/2026 +

+

+ At WildeBackyard (“we,” “us,” or “our”), a community science and social sharing initiative operated + in association with the Felidae Conservation Fund, we are committed to respecting your privacy while advancing + wildlife research and conservation. +

+

+ This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use the + WildeBackyard website, mobile applications, and associated services (collectively, the “Platform”). +

+

+ Please read this Privacy Policy carefully. By accessing or using the Platform, you acknowledge that you have read, + understood, and agree to the practices described in this policy. +

+
+

1. Information We Collect

+

+ We collect information directly from you, automatically through your use of the Platform, and from third-party + sources. +

+
A. Information You Provide Directly
+
    +
  • + Account Information: Name, username, email address, password, profile picture, and optional bio + when you register for an account. +
  • +
  • + Community Science & Social Uploads: Wildlife photos, video recordings, audio files, + observation notes, species tags, comments, likes, and community forum posts you contribute to the Platform. +
  • +
  • + Donations & Financial Transactions: If you make a donation or purchase through the Platform, + we collect billing details, donation amounts, and contact information. Payment processing details (such as + credit card numbers) are handled directly by secure third-party payment processors and are not stored on our + servers. +
  • +
  • + Communications: Information you provide when contacting us for support, participating in + surveys, or subscribing to updates. +
  • +
+
B. Location & Spatial Data (Community Science Observations)
+

Because WildeBackyard is a community science platform dedicated to ecological and wildlife research:

+
    +
  • + Geolocation & EXIF Metadata: When you upload wildlife photos or log sightings, we collect + precise or approximate geographic coordinates (latitude and longitude), elevation, date, and timestamp from your + device or media files. +
  • +
  • + Privacy Controls for Sensitive Locations: You may have the option to obscure or "blur" precise + observation locations (e.g., sightings on private property or involving sensitive/threatened species) according + to your platform preferences. +
  • +
+
C. Information Collected Automatically
+
    +
  • + Device & Usage Data: IP address, browser type, operating system, unique device identifiers, + pages viewed, time spent on pages, and navigation paths. +
  • +
  • + Cookies and Tracking Technologies: We use cookies, pixels, and similar tools to maintain session + state, remember user preferences, and analyze platform usage. +
  • +
+

2. How We Use Your Information

+

We use the information we collect for the following purposes:

+
    +
  • + Platform Operation & Social Sharing: To create and manage your account, display your public + profile and shared observations, facilitate social interaction (comments, likes, shares), and deliver core + features. +
  • +
  • + Scientific Research & Wildlife Conservation: To aggregate, analyze, and map wildlife + sighting data. This data helps researchers, conservation scientists, and ecological partners monitor + biodiversity, study wildlife corridors, and advance non-profit conservation efforts. +
  • +
  • + Community Engagement: To notify you about platform activity, updates on research projects you + contribute to, community challenges, and conservation news. +
  • +
  • + Processing Donations: To process contributions, issue tax receipts, and communicate regarding + fundraising initiatives. +
  • +
  • + Platform Security & Improvement: To maintain network security, troubleshoot technical + issues, prevent fraudulent activity, and enhance user experience. +
  • +
  • + Legal Compliance: To fulfill legal obligations and enforce our Terms of Service. +
  • +
+

3. How We Share Your Information

+

+ We do not sell your personal identification information to third parties. We share information only in the following + contexts: +

+
A. Public & Community Sharing
+
    +
  • + Public Sighting Data: Observations, species tags, non-obscured location data, photos, usernames, + and profile details you choose to share publicly on the Platform will be visible to other users and visitors. +
  • +
  • + Obscured / Private Observations: If you mark a location as private or if a species is flagged as + sensitive, location data displayed publicly may be generalized or obscured. +
  • +
+
B. Scientific & Conservation Research Partners
+
    +
  • + Community science data (including observation locations, timestamps, and media) may be shared with scientific + partners, universities, wildlife agencies, and affiliated conservation organizations (including the Felidae + Conservation Fund) for non-commercial research, environmental modeling, and habitat protection. +
  • +
+
C. Service Providers
+
    +
  • + We share data with trusted third-party vendors who assist us in operating the Platform (e.g., cloud hosting, data + analytics, email delivery, customer support, and payment gateways). These providers are bound by strict + confidentiality obligations. +
  • +
+
D. Legal & Safety Requirements
+
    +
  • + We may disclose information if required by law, court order, or government regulation, or if we believe in good + faith that disclosure is necessary to protect the rights, property, or safety of WildeBackyard, our users, + wildlife, or the public. +
  • +
+

4. Your Choices & Data Rights

+
    +
  • + Account & Profile Settings: You can review, update, or edit your account information and + profile visibility settings at any time through your account settings. +
  • +
  • + Location Controls: You can control location permissions via your device settings or choose to + blur/obscure specific observation locations prior to publishing. +
  • +
  • + Email Communications: You can opt out of promotional emails or newsletters by clicking the + "Unsubscribe" link in any promotional message. Essential transactional and account updates will still be sent. +
  • +
  • + Data Deletion: You may request the deletion of your account and personal data by contacting us + at privacy@felidaefund.org. Please note that anonymized or + aggregated community science research data previously contributed to research databases may be retained for + scientific integrity. +
  • +
+

5. Data Security

+

+ We implement appropriate technical and organizational security measures to safeguard your personal information + against unauthorized access, loss, alteration, or misuse. However, no internet transmission or electronic storage + method is 100% secure, and we cannot guarantee absolute security. +

+

6. Children’s Privacy

+

+ WildeBackyard is not intended for children under the age of 13 (or 16 in certain jurisdictions) without parental + consent. We do not knowingly collect personal information directly from children under these ages. If you believe a + child has provided us with personal information without parental consent, please contact us so we can take + appropriate steps to remove the information. +

+

7. Third-Party Links & Services

+

+ The Platform may contain links to third-party websites or services not operated by WildeBackyard (including partner + conservation sites). We are not responsible for the privacy practices or content of third-party websites. We + encourage you to review the privacy policies of any site you visit. +

+

8. Updates to This Privacy Policy

+

+ We may update this Privacy Policy from time to time to reflect changes in our practices, platform features, or legal + requirements. When we post updates, we will revise the "Last Updated" date at the top of this page. For material + changes, we will notify you through the Platform or via email. +

+

9. Contact Us

+

+ If you have any questions, concerns, or requests regarding this Privacy Policy or our data practices, please contact + us at: +

+

+ WildeBackyard / Felidae Conservation Fund +
+ Email: privacy@felidaefund.org / support@felidaefund.org +
+ Mailing Address: 655 Redwood Hwy, Suite 150, Mill Valley, CA 94941 +
+ Website: https://felidaefund.org / https://wildebackyard.com +

diff --git a/siteapps/templates/sightings/create_sighting.html b/siteapps/templates/sightings/create_sighting.html index 1bc4b5f..de8d8be 100644 --- a/siteapps/templates/sightings/create_sighting.html +++ b/siteapps/templates/sightings/create_sighting.html @@ -700,6 +700,21 @@

+
+
+ + +
@@ -877,6 +892,39 @@

+ +