feat: restyle identify_upload as camera-first viewfinder screen
This commit is contained in:
parent
4cac6cd0ce
commit
2c45a6f2ee
2 changed files with 126 additions and 84 deletions
|
|
@ -1,56 +1,96 @@
|
||||||
|
{# plants/templates/plants/identify_upload.html #}
|
||||||
{% extends "plants/base.html" %}
|
{% extends "plants/base.html" %}
|
||||||
{% block title %}Identify plant — BloomBase{% endblock %}
|
{% block title %}Identify plant — BloomBase{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="px-4 pt-6">
|
||||||
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary">←</a>
|
|
||||||
<h5 class="mb-0">Identify plant</h5>
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<a href="{% url 'dashboard' %}" class="btn-pill no-underline text-sm">←</a>
|
||||||
|
<h1 class="font-nunito font-black text-[20px] text-[#1A1208]">Identify plant</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-muted small mb-3">
|
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
|
||||||
Add one or more photos, tag what each shows, then tap Identify.
|
Add one or more photos, tag what each shows, then tap Identify.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-warning py-2 small mb-3">{{ error }}</div>
|
<div class="bg-orange-50 border border-orange-200 rounded-2xl px-4 py-3 font-nunito text-sm text-orange-700 mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data" id="identify-form">
|
<form method="post" enctype="multipart/form-data" id="identify-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="file" name="images" accept="image/jpeg,image/png" class="d-none" id="file-picker">
|
<input type="file" name="images" accept="image/jpeg,image/png" class="hidden" id="file-picker">
|
||||||
|
|
||||||
<div id="photo-rows" class="mb-3"></div>
|
<!-- Viewfinder / drop zone -->
|
||||||
|
<div class="relative bg-[#2C3820] rounded-2xl h-[140px] overflow-hidden mb-4" id="vf-box">
|
||||||
|
<div class="absolute inset-0"
|
||||||
|
style="background: radial-gradient(ellipse 60% 55% at 60% 45%, #C84060 0%, #E05878 30%, #F880A0 55%, transparent 75%),
|
||||||
|
linear-gradient(160deg, #1E3018 0%, #2E5028 30%, #3A6832 50%, #2C5020 70%);"></div>
|
||||||
|
<div class="vf-corner vf-tl"></div>
|
||||||
|
<div class="vf-corner vf-tr"></div>
|
||||||
|
<div class="vf-corner vf-bl"></div>
|
||||||
|
<div class="vf-corner vf-br"></div>
|
||||||
|
<!-- Controls row -->
|
||||||
|
<div class="absolute bottom-4 left-0 right-0 flex justify-center items-center gap-6">
|
||||||
|
<label for="file-picker"
|
||||||
|
class="w-9 h-9 bg-white/20 rounded-xl flex items-center justify-center border border-white/40 cursor-pointer">
|
||||||
|
<svg width="15" height="15" fill="none" stroke="white" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
<label for="file-picker"
|
||||||
|
class="bb-fab cursor-pointer">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="add-btn-wrap" class="mb-3">
|
<!-- Photo rows (populated by JS) -->
|
||||||
<label class="btn btn-outline-secondary w-100 py-3" for="file-picker" id="add-photo-label">
|
<div id="photo-rows" class="mb-4"></div>
|
||||||
📷 Add photo <span id="photo-count" class="text-muted small"></span>
|
|
||||||
|
<!-- Add more label (shown when < 5 photos) -->
|
||||||
|
<div id="add-btn-wrap" class="mb-4">
|
||||||
|
<label for="file-picker"
|
||||||
|
class="flex items-center justify-center gap-2 bg-white border border-[#F0E4D4] rounded-2xl py-3
|
||||||
|
font-nunito font-bold text-sm text-[#B0A090] cursor-pointer">
|
||||||
|
📷 Add photo <span id="photo-count" class="text-[#C0B0A0]"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-success w-100" id="identify-btn" disabled>
|
<button type="submit" id="identify-btn" disabled
|
||||||
|
class="btn-bloom opacity-50 cursor-not-allowed">
|
||||||
Identify →
|
Identify →
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="text-center mt-3">
|
<div class="text-center mt-4">
|
||||||
<a href="{% url 'plant_add' %}" class="text-muted small">Or enter name manually</a>
|
<a href="{% url 'plant_add' %}" class="font-nunito text-sm font-semibold text-[#B0A090] no-underline">
|
||||||
|
Or enter name manually
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="identify-spinner" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,.88); z-index:9999; align-items:center; justify-content:center; flex-direction:column; gap:12px;">
|
<!-- Loading overlay -->
|
||||||
<div class="spinner-border text-success" role="status"><span class="visually-hidden">Identifying…</span></div>
|
<div id="identify-spinner"
|
||||||
<p class="text-muted small mb-0">Identifying plant…</p>
|
style="display:none; position:fixed; inset:0; background:rgba(255,248,242,.92); z-index:9999;
|
||||||
|
align-items:center; justify-content:center; flex-direction:column; gap:16px;">
|
||||||
|
<div style="width:40px;height:40px;border:3px solid #F0E4D4;border-top-color:#F07040;
|
||||||
|
border-radius:50%;animation:spin .8s linear infinite;"></div>
|
||||||
|
<p class="font-nunito font-bold text-sm text-[#B0A090]">Identifying plant…</p>
|
||||||
|
</div>
|
||||||
|
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const ORGANS = [
|
const ORGANS = [
|
||||||
{val: 'flower', label: '🌸 flower'},
|
{val:'flower',label:'🌸 flower'},{val:'leaf',label:'🍃 leaf'},
|
||||||
{val: 'leaf', label: '🍃 leaf'},
|
{val:'habit',label:'🌿 habit'},{val:'fruit',label:'🍎 fruit'},
|
||||||
{val: 'habit', label: '🌿 habit'},
|
{val:'bark',label:'🪵 bark'},{val:'auto',label:'✨ auto'},
|
||||||
{val: 'fruit', label: '🍎 fruit'},
|
|
||||||
{val: 'bark', label: '🪵 bark'},
|
|
||||||
{val: 'auto', label: '✨ auto'},
|
|
||||||
];
|
];
|
||||||
const selected = []; // {file, organ, objectUrl}
|
const selected = [];
|
||||||
const picker = document.getElementById('file-picker');
|
const picker = document.getElementById('file-picker');
|
||||||
|
|
||||||
picker.addEventListener('change', function () {
|
picker.addEventListener('change', function () {
|
||||||
|
|
@ -60,10 +100,7 @@ picker.addEventListener('change', function () {
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
function setOrgan(idx, organ) {
|
function setOrgan(idx, organ) { selected[idx].organ = organ; render(); }
|
||||||
selected[idx].organ = organ;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePhoto(idx) {
|
function removePhoto(idx) {
|
||||||
URL.revokeObjectURL(selected[idx].objectUrl);
|
URL.revokeObjectURL(selected[idx].objectUrl);
|
||||||
|
|
@ -76,22 +113,24 @@ function render() {
|
||||||
rows.innerHTML = '';
|
rows.innerHTML = '';
|
||||||
selected.forEach((item, i) => {
|
selected.forEach((item, i) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'd-flex gap-2 align-items-start mb-3';
|
row.className = 'flex gap-3 items-start mb-4';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div style="position:relative;flex-shrink:0;">
|
<div style="position:relative;flex-shrink:0;">
|
||||||
<img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:8px;" alt="">
|
<img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:14px;">
|
||||||
<button type="button" onclick="removePhoto(${i})"
|
<button type="button" onclick="removePhoto(${i})"
|
||||||
style="position:absolute;top:2px;right:2px;background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:50%;width:20px;height:20px;font-size:10px;cursor:pointer;padding:0;line-height:1;">✕</button>
|
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.5);color:#fff;
|
||||||
|
border:none;border-radius:50%;width:20px;height:20px;font-size:10px;
|
||||||
|
cursor:pointer;padding:0;line-height:1;">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<div class="text-muted small mb-1">What's shown?</div>
|
<div style="font-family:Nunito,sans-serif;font-size:11px;font-weight:700;color:#B0A090;margin-bottom:6px;">What's shown?</div>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div style="display:flex;flex-wrap:wrap;gap:5px;">
|
||||||
${ORGANS.map(o=>`
|
${ORGANS.map(o=>`
|
||||||
<span onclick="setOrgan(${i},'${o.val}')"
|
<span onclick="setOrgan(${i},'${o.val}')"
|
||||||
style="cursor:pointer; padding:2px 8px; border-radius:10px; font-size:11px; ${item.organ === o.val ? 'background:#52b788;color:#fff;' : 'background:#e9ecef;color:#6c757d;'}">
|
style="cursor:pointer;padding:3px 10px;border-radius:20px;font-size:11px;font-family:Nunito,sans-serif;font-weight:700;
|
||||||
|
${item.organ===o.val?'background:#F07040;color:#fff;':'background:#F5F0EA;color:#B0A090;'}">
|
||||||
${o.label}
|
${o.label}
|
||||||
</span>
|
</span>`).join('')}
|
||||||
`).join('')}
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
|
<input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -104,7 +143,10 @@ function render() {
|
||||||
|
|
||||||
document.getElementById('photo-count').textContent = selected.length ? `(${selected.length}/5)` : '';
|
document.getElementById('photo-count').textContent = selected.length ? `(${selected.length}/5)` : '';
|
||||||
document.getElementById('add-btn-wrap').style.display = selected.length >= 5 ? 'none' : '';
|
document.getElementById('add-btn-wrap').style.display = selected.length >= 5 ? 'none' : '';
|
||||||
document.getElementById('identify-btn').disabled = selected.length === 0;
|
const btn = document.getElementById('identify-btn');
|
||||||
|
btn.disabled = selected.length === 0;
|
||||||
|
btn.style.opacity = selected.length === 0 ? '0.5' : '1';
|
||||||
|
btn.style.cursor = selected.length === 0 ? 'not-allowed' : 'pointer';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('identify-form').addEventListener('submit', function(e) {
|
document.getElementById('identify-form').addEventListener('submit', function(e) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from django.core.files.storage import default_storage
|
||||||
from plants.services import plantnet
|
from plants.services import plantnet
|
||||||
from plants.services.plantnet import PlantNetError
|
from plants.services.plantnet import PlantNetError
|
||||||
from plants.services.vpc import search_species as vpc_search
|
from plants.services.vpc import search_species as vpc_search
|
||||||
from plants.models import Plant, PlantPhoto, Species
|
from plants.models import Plant, PlantPhoto, Species, Location
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -27,14 +27,14 @@ def _clear_session(request):
|
||||||
|
|
||||||
def identify_upload(request):
|
def identify_upload(request):
|
||||||
if request.method != 'POST':
|
if request.method != 'POST':
|
||||||
return render(request, 'plants/identify_upload.html')
|
return render(request, 'plants/identify_upload.html', {'active_tab': 'home'})
|
||||||
|
|
||||||
files = request.FILES.getlist('images')
|
files = request.FILES.getlist('images')
|
||||||
organs = request.POST.getlist('organs')
|
organs = request.POST.getlist('organs')
|
||||||
|
|
||||||
if not files:
|
if not files:
|
||||||
return render(request, 'plants/identify_upload.html',
|
return render(request, 'plants/identify_upload.html',
|
||||||
{'error': 'Please select at least one photo.'})
|
{'error': 'Please select at least one photo.', 'active_tab': 'home'})
|
||||||
|
|
||||||
scan_id = uuid.uuid4().hex
|
scan_id = uuid.uuid4().hex
|
||||||
saved_paths = []
|
saved_paths = []
|
||||||
|
|
@ -66,7 +66,7 @@ def identify_upload(request):
|
||||||
default_storage.delete(p['path'])
|
default_storage.delete(p['path'])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return render(request, 'plants/identify_upload.html', {'error': error_msg})
|
return render(request, 'plants/identify_upload.html', {'error': error_msg, 'active_tab': 'home'})
|
||||||
|
|
||||||
request.session['identify_matches'] = matches
|
request.session['identify_matches'] = matches
|
||||||
request.session['identify_image_paths'] = saved_paths
|
request.session['identify_image_paths'] = saved_paths
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue