1613 lines
61 KiB
Markdown
1613 lines
61 KiB
Markdown
# BloomBase UI Redesign — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace Bootstrap 5 with Tailwind CSS, add a 5-tab bottom navigation, extend `Location` with a cover photo field, restyle all existing screens, and add a new Area Detail page — producing the vibrant organic aesthetic from Naomi's mockup.
|
||
|
||
**Architecture:** New `bloombase.css` holds fonts + CSS blob/flower decorations; `base.html` is fully rewritten with Tailwind CDN + bottom nav; all child templates are rewritten to use Tailwind utilities; one new view + template (`location_detail`) handles the Area Detail screen; `Location` model gains a nullable `cover_photo` ImageField with its own migration.
|
||
|
||
**Tech Stack:** Django 5.x, HTMX 1.9, Tailwind CSS Play CDN (development), Google Fonts (Great Vibes + Nunito + Pacifico), SQLite
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| File | Action |
|
||
|---|---|
|
||
| `plants/static/plants/css/bloombase.css` | **Create** — fonts, blob shapes, canvas flowers |
|
||
| `plants/templates/plants/base.html` | **Rewrite** — Tailwind, bottom nav, decorations |
|
||
| `plants/models.py` | **Modify** — add `cover_photo` to `Location` |
|
||
| `plants/migrations/XXXX_location_cover_photo.py` | **Create** via `makemigrations` |
|
||
| `plants/forms.py` | **Modify** — add `LocationForm` with `cover_photo` |
|
||
| `plants/views/locations.py` | **Modify** — use `LocationForm`; add `location_detail` view |
|
||
| `plants/urls.py` | **Modify** — add `location_detail` URL |
|
||
| `plants/templates/plants/locations.html` | **Rewrite** — My Garden screen |
|
||
| `plants/templates/plants/location_detail.html` | **Create** — Area Detail screen |
|
||
| `plants/templates/plants/location_edit.html` | **Rewrite** — add cover photo field |
|
||
| `plants/templates/plants/dashboard.html` | **Rewrite** — camera-first Home screen |
|
||
| `plants/templates/plants/plant_list.html` | **Rewrite** — search + filter chips |
|
||
| `plants/templates/plants/plant_detail.html` | **Rewrite** — hero + care + CTA |
|
||
| `plants/templates/plants/identify_upload.html` | **Rewrite** — viewfinder camera UI |
|
||
| `plants/templates/plants/identify_confirm.html` | **Rewrite** — match list restyle |
|
||
| `plants/tests/test_location_redesign.py` | **Create** — model + view tests |
|
||
|
||
---
|
||
|
||
## Task 1: Static CSS — bloombase.css
|
||
|
||
Creates the custom stylesheet: Google Fonts import, CSS blob background shapes, and fixed-position SVG flower decorations.
|
||
|
||
**Files:**
|
||
- Create: `plants/static/plants/css/bloombase.css`
|
||
|
||
- [ ] **Step 1.1: Create the static directory and file**
|
||
|
||
```bash
|
||
mkdir -p plants/static/plants/css
|
||
```
|
||
|
||
- [ ] **Step 1.2: Write bloombase.css**
|
||
|
||
```css
|
||
/* plants/static/plants/css/bloombase.css */
|
||
|
||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Great+Vibes&family=Pacifico&display=swap');
|
||
|
||
/* ── Body canvas ── */
|
||
body {
|
||
background-color: #FFF8F2;
|
||
font-family: 'Nunito', sans-serif;
|
||
min-height: 100vh;
|
||
position: relative;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
/* ── Background blob shapes ── */
|
||
.bb-blob-tl {
|
||
position: fixed; top: -100px; left: -80px;
|
||
width: 380px; height: 380px;
|
||
background: radial-gradient(ellipse at 45% 45%, #F9C090 0%, #F07040 55%, #E05020 100%);
|
||
border-radius: 62% 38% 54% 46% / 58% 44% 56% 42%;
|
||
opacity: 0.18;
|
||
pointer-events: none; z-index: 0;
|
||
}
|
||
.bb-blob-br {
|
||
position: fixed; bottom: -80px; right: -60px;
|
||
width: 340px; height: 340px;
|
||
background: radial-gradient(ellipse at 55% 55%, #FFD080 0%, #F8A040 60%, #F07040 100%);
|
||
border-radius: 44% 56% 38% 62% / 52% 48% 52% 48%;
|
||
opacity: 0.16;
|
||
pointer-events: none; z-index: 0;
|
||
}
|
||
|
||
/* ── Canvas SVG flowers ── */
|
||
.bb-flower { position: fixed; pointer-events: none; z-index: 0; }
|
||
.bb-flower-tr { top: 16px; right: 24px; }
|
||
.bb-flower-ml { top: 38%; left: -4px; }
|
||
.bb-flower-br { bottom: 72px; right: 48px; }
|
||
.bb-flower-bl { bottom: 88px; left: 32px; }
|
||
|
||
/* ── Brand fonts ── */
|
||
.font-script { font-family: 'Great Vibes', cursive; }
|
||
.font-pacifico { font-family: 'Pacifico', cursive; }
|
||
.font-nunito { font-family: 'Nunito', sans-serif; }
|
||
|
||
/* ── Bottom nav stays above content ── */
|
||
#bottom-nav {
|
||
position: fixed; bottom: 0; left: 0; right: 0;
|
||
background: #fff;
|
||
border-top: 1px solid #F0E8DF;
|
||
height: 62px;
|
||
display: flex; align-items: center; justify-content: space-around;
|
||
z-index: 50;
|
||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||
}
|
||
|
||
/* ── FAB inside nav ── */
|
||
.bb-fab {
|
||
width: 44px; height: 44px; border-radius: 50%;
|
||
background: linear-gradient(145deg, #F58040, #E84020);
|
||
box-shadow: 0 4px 16px rgba(240, 80, 40, 0.5);
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: #fff; font-size: 24px; line-height: 1; font-weight: 300;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Gradient CTA button ── */
|
||
.btn-bloom {
|
||
display: block; width: 100%;
|
||
background: linear-gradient(135deg, #F58040, #E84020);
|
||
color: #fff; border: none; border-radius: 50px;
|
||
padding: 12px 24px; font-family: 'Nunito', sans-serif;
|
||
font-weight: 800; font-size: 15px; text-align: center;
|
||
box-shadow: 0 4px 14px rgba(240,80,40,0.4); cursor: pointer;
|
||
text-decoration: none;
|
||
}
|
||
.btn-bloom-outline {
|
||
display: block; width: 100%;
|
||
background: transparent; color: #1A1208;
|
||
border: 1.5px solid #E0D4C8; border-radius: 50px;
|
||
padding: 11px 24px; font-family: 'Nunito', sans-serif;
|
||
font-weight: 700; font-size: 15px; text-align: center;
|
||
cursor: pointer; text-decoration: none;
|
||
}
|
||
|
||
/* ── White pill button (hero overlays) ── */
|
||
.btn-pill {
|
||
background: rgba(255,255,255,0.88);
|
||
border: none; border-radius: 50px;
|
||
padding: 6px 14px; font-size: 13px; font-weight: 700;
|
||
color: #1A1208; backdrop-filter: blur(4px); cursor: pointer;
|
||
text-decoration: none;
|
||
}
|
||
|
||
/* ── Confidence bar ── */
|
||
.conf-track { height: 5px; background: #F0E4D8; border-radius: 3px; overflow: hidden; }
|
||
.conf-fill { height: 100%; background: linear-gradient(90deg, #F8A060, #E84020); border-radius: 3px; }
|
||
|
||
/* ── Area thumbnail gradient placeholders ── */
|
||
.at-placeholder-1 { background: linear-gradient(135deg, #88C860, #E85878, #A0D060); }
|
||
.at-placeholder-2 { background: linear-gradient(150deg, #B8E060, #F0A840, #E87830); }
|
||
.at-placeholder-3 { background: linear-gradient(120deg, #F8B840, #88C060, #F0C860); }
|
||
.at-placeholder-4 { background: linear-gradient(140deg, #D0A8E8, #68B858, #C888E0); }
|
||
.at-placeholder-5 { background: linear-gradient(130deg, #78D8C8, #E8F0A0, #60C8A8); }
|
||
.at-placeholder-6 { background: linear-gradient(125deg, #F8A0B0, #B8E060, #F09060); }
|
||
|
||
/* ── Viewfinder corners ── */
|
||
.vf-corner { position: absolute; width: 18px; height: 18px; border-color: rgba(255,255,255,0.9); border-style: solid; }
|
||
.vf-tl { top: 10px; left: 10px; border-width: 2.5px 0 0 2.5px; border-radius: 2px 0 0 0; }
|
||
.vf-tr { top: 10px; right: 10px; border-width: 2.5px 2.5px 0 0; border-radius: 0 2px 0 0; }
|
||
.vf-bl { bottom: 10px; left: 10px; border-width: 0 0 2.5px 2.5px; border-radius: 0 0 0 2px; }
|
||
.vf-br { bottom: 10px; right: 10px; border-width: 0 2.5px 2.5px 0; border-radius: 0 0 2px 0; }
|
||
```
|
||
|
||
- [ ] **Step 1.3: Commit**
|
||
|
||
```bash
|
||
git add plants/static/plants/css/bloombase.css
|
||
git commit -m "feat: add bloombase.css — fonts, blobs, canvas flowers, shared components"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Rewrite base.html
|
||
|
||
Full replacement of the Bootstrap top-nav shell with Tailwind CDN + 5-tab bottom nav + background canvas decorations.
|
||
|
||
**Files:**
|
||
- Modify: `plants/templates/plants/base.html`
|
||
|
||
- [ ] **Step 2.1: Replace base.html**
|
||
|
||
```html
|
||
{# plants/templates/plants/base.html #}
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||
<title>{% block title %}BloomBase{% endblock %}</title>
|
||
{% load static %}
|
||
<link rel="stylesheet" href="{% static 'plants/css/bloombase.css' %}">
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
bloom: {
|
||
bg: '#FFF8F2', primary: '#F07040', green: '#52B788',
|
||
dark: '#1A1208', muted: '#B0A090', border: '#F0E4D4',
|
||
}
|
||
},
|
||
fontFamily: {
|
||
nunito: ['Nunito', 'sans-serif'],
|
||
script: ['"Great Vibes"', 'cursive'],
|
||
pacifico: ['Pacifico', 'cursive'],
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||
{% block extra_head %}{% endblock %}
|
||
</head>
|
||
<body class="bg-[#FFF8F2]">
|
||
|
||
<!-- Canvas background blobs -->
|
||
<div class="bb-blob-tl"></div>
|
||
<div class="bb-blob-br"></div>
|
||
|
||
<!-- Canvas SVG flowers -->
|
||
<svg class="bb-flower bb-flower-tr" width="140" height="140" viewBox="0 0 140 140" xmlns="http://www.w3.org/2000/svg" opacity="0.85">
|
||
<defs>
|
||
<radialGradient id="hp1" cx="50%" cy="80%" r="85%"><stop offset="0%" stop-color="#A02848"/><stop offset="35%" stop-color="#D04870"/><stop offset="70%" stop-color="#F07898"/><stop offset="100%" stop-color="#FFCCD8"/></radialGradient>
|
||
<radialGradient id="hp2" cx="50%" cy="80%" r="85%"><stop offset="0%" stop-color="#B83060"/><stop offset="35%" stop-color="#E06080"/><stop offset="70%" stop-color="#F898B0"/><stop offset="100%" stop-color="#FFDDEA"/></radialGradient>
|
||
</defs>
|
||
<g transform="translate(70,70)">
|
||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(0)"/>
|
||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp2)" transform="rotate(72)" opacity="0.92"/>
|
||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(144)" opacity="0.88"/>
|
||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp2)" transform="rotate(216)" opacity="0.93"/>
|
||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(288)" opacity="0.87"/>
|
||
<rect x="-3.5" y="-20" width="7" height="20" rx="3.5" fill="#FFD060"/>
|
||
<circle r="6" fill="#FFD040"/><circle r="3" fill="#E84020"/>
|
||
</g>
|
||
</svg>
|
||
|
||
<svg class="bb-flower bb-flower-ml" width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" opacity="0.7">
|
||
<defs><radialGradient id="pp" cx="50%" cy="80%" r="80%"><stop offset="0%" stop-color="#C03058"/><stop offset="50%" stop-color="#E86090"/><stop offset="100%" stop-color="#FFB8CC"/></radialGradient></defs>
|
||
<g transform="translate(36,36)">
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(0)"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(72)" opacity="0.9"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(144)"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(216)" opacity="0.9"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(288)"/>
|
||
<circle r="5" fill="#FFD040"/><circle r="2.5" fill="#E84020"/>
|
||
</g>
|
||
</svg>
|
||
|
||
<svg class="bb-flower bb-flower-br" width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg" opacity="0.8">
|
||
<defs><radialGradient id="yp" cx="50%" cy="80%" r="80%"><stop offset="0%" stop-color="#D08000"/><stop offset="50%" stop-color="#F0C020"/><stop offset="100%" stop-color="#FFE880"/></radialGradient></defs>
|
||
<g transform="translate(48,48)">
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(0)"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(45)" opacity="0.9"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(90)"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(135)" opacity="0.9"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(180)"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(225)" opacity="0.9"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(270)"/>
|
||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(315)" opacity="0.9"/>
|
||
<circle r="9" fill="#E06010"/><circle r="5" fill="#C04000"/>
|
||
</g>
|
||
</svg>
|
||
|
||
<svg class="bb-flower bb-flower-bl" width="56" height="56" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg" opacity="0.65">
|
||
<g transform="translate(28,28)">
|
||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(0)"/>
|
||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(60)"/>
|
||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(120)"/>
|
||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(180)"/>
|
||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(240)"/>
|
||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(300)"/>
|
||
</g>
|
||
</svg>
|
||
|
||
<!-- Page content — pb-20 keeps content above fixed bottom nav -->
|
||
<main class="relative z-10 pb-24 min-h-screen">
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
|
||
<!-- Bottom navigation -->
|
||
<nav id="bottom-nav" aria-label="Main navigation">
|
||
{% url 'dashboard' as url_home %}
|
||
{% url 'location_list' as url_garden %}
|
||
{% url 'identify_upload' as url_identify %}
|
||
|
||
<!-- Home -->
|
||
<a href="{{ url_home }}"
|
||
class="flex flex-col items-center gap-0.5 text-[7.5px] font-bold font-nunito flex-1
|
||
{% if active_tab == 'home' %}text-[#F07040]{% else %}text-[#C8B8A8]{% endif %}">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="{% if active_tab == 'home' %}#F07040{% else %}#C8B8A8{% endif %}">
|
||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
|
||
</svg>
|
||
Home
|
||
</a>
|
||
|
||
<!-- My Garden -->
|
||
<a href="{{ url_garden }}"
|
||
class="flex flex-col items-center gap-0.5 text-[7.5px] font-bold font-nunito flex-1
|
||
{% if active_tab == 'garden' %}text-[#F07040]{% else %}text-[#C8B8A8]{% endif %}">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||
stroke="{% if active_tab == 'garden' %}#F07040{% else %}#C8B8A8{% endif %}" stroke-width="2">
|
||
<circle cx="12" cy="12" r="3"/>
|
||
<path d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z"/>
|
||
</svg>
|
||
My Garden
|
||
</a>
|
||
|
||
<!-- FAB: Identify -->
|
||
<a href="{{ url_identify }}" class="flex flex-col items-center justify-center flex-1">
|
||
<div class="bb-fab">+</div>
|
||
</a>
|
||
|
||
<!-- Care (stub) -->
|
||
<a href="#"
|
||
class="flex flex-col items-center gap-0.5 text-[7.5px] font-bold font-nunito flex-1
|
||
{% if active_tab == 'care' %}text-[#F07040]{% else %}text-[#C8B8A8]{% endif %}">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||
stroke="{% if active_tab == 'care' %}#F07040{% else %}#C8B8A8{% endif %}" stroke-width="2"
|
||
stroke-linecap="round">
|
||
<path d="M12 2a3 3 0 0 1 3 3v7a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z"/>
|
||
<path d="M19 10a7 7 0 0 1-14 0"/>
|
||
<line x1="12" y1="19" x2="12" y2="22"/>
|
||
</svg>
|
||
Care
|
||
</a>
|
||
|
||
<!-- Profile (stub) -->
|
||
<a href="#"
|
||
class="flex flex-col items-center gap-0.5 text-[7.5px] font-bold font-nunito flex-1
|
||
{% if active_tab == 'profile' %}text-[#F07040]{% else %}text-[#C8B8A8]{% endif %}">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||
stroke="{% if active_tab == 'profile' %}#F07040{% else %}#C8B8A8{% endif %}" stroke-width="2"
|
||
stroke-linecap="round">
|
||
<circle cx="12" cy="8" r="4"/>
|
||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||
</svg>
|
||
Profile
|
||
</a>
|
||
</nav>
|
||
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 2.2: Verify the app still loads (no crash)**
|
||
|
||
```bash
|
||
cd /home/stephan/devel/python/plantdb
|
||
python manage.py check
|
||
```
|
||
|
||
Expected: `System check identified no issues (0 silenced).`
|
||
|
||
- [ ] **Step 2.3: Commit**
|
||
|
||
```bash
|
||
git add plants/templates/plants/base.html
|
||
git commit -m "feat: rewrite base.html — Tailwind CDN, 5-tab bottom nav, canvas decorations"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Location model — add cover_photo + migration
|
||
|
||
**Files:**
|
||
- Modify: `plants/models.py`
|
||
- Create: migration (via `makemigrations`)
|
||
- Create: `plants/tests/test_location_redesign.py`
|
||
|
||
- [ ] **Step 3.1: Write failing test first**
|
||
|
||
```python
|
||
# plants/tests/test_location_redesign.py
|
||
import pytest
|
||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||
from plants.models import Location
|
||
|
||
|
||
@pytest.mark.django_db
|
||
def test_location_cover_photo_field_exists():
|
||
"""Location can be saved with and without a cover_photo."""
|
||
loc = Location.objects.create(name='Test Garden')
|
||
assert loc.cover_photo.name == '' or loc.cover_photo.name is None
|
||
|
||
fake_img = SimpleUploadedFile('cover.jpg', b'fake-image-data', content_type='image/jpeg')
|
||
loc.cover_photo = fake_img
|
||
loc.save()
|
||
loc.refresh_from_db()
|
||
assert loc.cover_photo.name.startswith('locations/')
|
||
```
|
||
|
||
- [ ] **Step 3.2: Run test — expect failure**
|
||
|
||
```bash
|
||
python -m pytest plants/tests/test_location_redesign.py::test_location_cover_photo_field_exists -v
|
||
```
|
||
|
||
Expected: `FAILED` — `AttributeError: 'Location' object has no attribute 'cover_photo'`
|
||
|
||
- [ ] **Step 3.3: Add cover_photo to Location model**
|
||
|
||
In `plants/models.py`, change:
|
||
|
||
```python
|
||
class Location(models.Model):
|
||
name = models.CharField(max_length=200, unique=True)
|
||
```
|
||
|
||
to:
|
||
|
||
```python
|
||
class Location(models.Model):
|
||
name = models.CharField(max_length=200, unique=True)
|
||
cover_photo = models.ImageField(upload_to='locations/', blank=True, null=True)
|
||
```
|
||
|
||
- [ ] **Step 3.4: Create migration**
|
||
|
||
```bash
|
||
python manage.py makemigrations plants --name location_cover_photo
|
||
```
|
||
|
||
Expected output: `Migrations for 'plants': plants/migrations/XXXX_location_cover_photo.py`
|
||
|
||
- [ ] **Step 3.5: Apply migration**
|
||
|
||
```bash
|
||
python manage.py migrate
|
||
```
|
||
|
||
Expected: `Applying plants.XXXX_location_cover_photo... OK`
|
||
|
||
- [ ] **Step 3.6: Run test — expect pass**
|
||
|
||
```bash
|
||
python -m pytest plants/tests/test_location_redesign.py::test_location_cover_photo_field_exists -v
|
||
```
|
||
|
||
Expected: `PASSED`
|
||
|
||
- [ ] **Step 3.7: Commit**
|
||
|
||
```bash
|
||
git add plants/models.py plants/migrations/ plants/tests/test_location_redesign.py
|
||
git commit -m "feat: add Location.cover_photo ImageField + migration + test"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: LocationForm + updated views + new location_detail + URL
|
||
|
||
**Files:**
|
||
- Modify: `plants/forms.py`
|
||
- Modify: `plants/views/locations.py`
|
||
- Modify: `plants/urls.py`
|
||
- Modify: `plants/tests/test_location_redesign.py`
|
||
|
||
- [ ] **Step 4.1: Write failing tests**
|
||
|
||
Append to `plants/tests/test_location_redesign.py`:
|
||
|
||
```python
|
||
from django.test import Client
|
||
from django.urls import reverse
|
||
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
return Client()
|
||
|
||
|
||
@pytest.mark.django_db
|
||
def test_location_detail_view_returns_200(client):
|
||
loc = Location.objects.create(name='My Terrace')
|
||
resp = client.get(reverse('location_detail', kwargs={'pk': loc.pk}))
|
||
assert resp.status_code == 200
|
||
assert b'My Terrace' in resp.content
|
||
|
||
|
||
@pytest.mark.django_db
|
||
def test_location_edit_with_cover_photo(client):
|
||
loc = Location.objects.create(name='Balcony')
|
||
fake_img = SimpleUploadedFile('cover.jpg', b'data', content_type='image/jpeg')
|
||
resp = client.post(
|
||
reverse('location_edit', kwargs={'pk': loc.pk}),
|
||
{'name': 'Balcony Updated', 'cover_photo': fake_img},
|
||
)
|
||
assert resp.status_code == 302
|
||
loc.refresh_from_db()
|
||
assert loc.name == 'Balcony Updated'
|
||
assert loc.cover_photo.name.startswith('locations/')
|
||
```
|
||
|
||
- [ ] **Step 4.2: Run tests — expect failure**
|
||
|
||
```bash
|
||
python -m pytest plants/tests/test_location_redesign.py -v -k "detail or edit_with_cover"
|
||
```
|
||
|
||
Expected: `FAILED` — `NoReverseMatch: location_detail` and related errors.
|
||
|
||
- [ ] **Step 4.3: Add LocationForm to forms.py**
|
||
|
||
Append to `plants/forms.py`:
|
||
|
||
```python
|
||
class LocationForm(forms.ModelForm):
|
||
class Meta:
|
||
model = Location
|
||
fields = ['name', 'cover_photo']
|
||
```
|
||
|
||
- [ ] **Step 4.4: Rewrite views/locations.py**
|
||
|
||
```python
|
||
# plants/views/locations.py
|
||
from django.shortcuts import render, get_object_or_404, redirect
|
||
from django.views.decorators.http import require_POST
|
||
from django.db.models import Count
|
||
from plants.models import Location, Plant
|
||
from plants.forms import LocationForm
|
||
|
||
|
||
def location_list(request):
|
||
if request.method == 'POST':
|
||
name = request.POST.get('name', '').strip()
|
||
if name:
|
||
Location.objects.get_or_create(name=name)
|
||
return redirect('location_list')
|
||
|
||
locations = Location.objects.annotate(plant_count=Count('plants'))
|
||
return render(request, 'plants/locations.html', {
|
||
'locations': locations,
|
||
'active_tab': 'garden',
|
||
})
|
||
|
||
|
||
def location_detail(request, pk):
|
||
location = get_object_or_404(Location, pk=pk)
|
||
plants = location.plants.select_related('species').prefetch_related('photos').all()
|
||
return render(request, 'plants/location_detail.html', {
|
||
'location': location,
|
||
'plants': plants,
|
||
'active_tab': 'garden',
|
||
})
|
||
|
||
|
||
def location_edit(request, pk):
|
||
location = get_object_or_404(Location, pk=pk)
|
||
if request.method == 'POST':
|
||
form = LocationForm(request.POST, request.FILES, instance=location)
|
||
if form.is_valid():
|
||
form.save()
|
||
return redirect('location_list')
|
||
else:
|
||
form = LocationForm(instance=location)
|
||
return render(request, 'plants/location_edit.html', {
|
||
'location': location,
|
||
'form': form,
|
||
'active_tab': 'garden',
|
||
})
|
||
|
||
|
||
@require_POST
|
||
def location_delete(request, pk):
|
||
location = get_object_or_404(Location, pk=pk)
|
||
Plant.objects.filter(location=location).update(location=None)
|
||
location.delete()
|
||
return redirect('location_list')
|
||
```
|
||
|
||
- [ ] **Step 4.5: Add location_detail URL**
|
||
|
||
In `plants/urls.py`, add after the existing `locations/` URL:
|
||
|
||
```python
|
||
path('locations/<int:pk>/', locations.location_detail, name='location_detail'),
|
||
```
|
||
|
||
The full relevant section of urlpatterns should look like:
|
||
|
||
```python
|
||
path('locations/', locations.location_list, name='location_list'),
|
||
path('locations/<int:pk>/', locations.location_detail, name='location_detail'),
|
||
path('locations/<int:pk>/edit/', locations.location_edit, name='location_edit'),
|
||
path('locations/<int:pk>/delete/', locations.location_delete, name='location_delete'),
|
||
```
|
||
|
||
- [ ] **Step 4.6: Run tests — expect pass**
|
||
|
||
```bash
|
||
python -m pytest plants/tests/test_location_redesign.py -v
|
||
```
|
||
|
||
Expected: all `PASSED`
|
||
|
||
- [ ] **Step 4.7: Commit**
|
||
|
||
```bash
|
||
git add plants/forms.py plants/views/locations.py plants/urls.py plants/tests/test_location_redesign.py
|
||
git commit -m "feat: add LocationForm with cover_photo, location_detail view, update location_edit"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: locations.html — My Garden screen
|
||
|
||
**Files:**
|
||
- Modify: `plants/templates/plants/locations.html`
|
||
|
||
- [ ] **Step 5.1: Rewrite locations.html**
|
||
|
||
```html
|
||
{# plants/templates/plants/locations.html #}
|
||
{% extends "plants/base.html" %}
|
||
{% block title %}My Garden — BloomBase{% endblock %}
|
||
{% block content %}
|
||
{% load static %}
|
||
|
||
<div class="px-4 pt-8 pb-4">
|
||
|
||
<!-- Header row -->
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h1 class="font-nunito font-black text-[22px] text-[#1A1208]">My Garden</h1>
|
||
<a href="#" class="font-nunito font-bold text-sm text-[#F07040]">Edit</a>
|
||
</div>
|
||
|
||
<!-- Search bar -->
|
||
<div class="flex items-center gap-2 bg-[#F5F0EA] rounded-full px-4 py-2.5 mb-4">
|
||
<svg width="16" height="16" fill="none" stroke="#B0A090" stroke-width="2" viewBox="0 0 24 24">
|
||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||
</svg>
|
||
<span class="font-nunito text-sm font-semibold text-[#C0B0A0]">Search plants or areas</span>
|
||
</div>
|
||
|
||
<!-- Area rows -->
|
||
<div class="flex flex-col gap-3 mb-6">
|
||
{% for loc in locations %}
|
||
{% with idx=forloop.counter0 %}
|
||
<a href="{% url 'location_detail' loc.pk %}"
|
||
class="flex items-center bg-white rounded-2xl overflow-hidden shadow-sm no-underline">
|
||
<!-- Thumbnail -->
|
||
<div class="w-[72px] h-[60px] flex-shrink-0 overflow-hidden">
|
||
{% if loc.cover_photo %}
|
||
<img src="{{ loc.cover_photo.url }}" alt="{{ loc.name }}"
|
||
class="w-full h-full object-cover">
|
||
{% else %}
|
||
<div class="w-full h-full
|
||
{% if idx|divisibleby:5 %}at-placeholder-5
|
||
{% elif idx|divisibleby:4 %}at-placeholder-4
|
||
{% elif idx|divisibleby:3 %}at-placeholder-3
|
||
{% elif idx|divisibleby:2 %}at-placeholder-2
|
||
{% else %}at-placeholder-1{% endif %}"></div>
|
||
{% endif %}
|
||
</div>
|
||
<!-- Info -->
|
||
<div class="flex-1 px-3">
|
||
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ loc.name }}</div>
|
||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] mt-0.5">
|
||
{{ loc.plant_count }} plant{{ loc.plant_count|pluralize }}
|
||
</div>
|
||
</div>
|
||
<!-- Chevron -->
|
||
<span class="text-[#D0C0B0] font-bold text-lg pr-3">›</span>
|
||
</a>
|
||
{% endwith %}
|
||
{% empty %}
|
||
<p class="font-nunito text-sm text-[#B0A090] text-center py-8">
|
||
No areas yet — add one below!
|
||
</p>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Add area form -->
|
||
<form method="post" class="flex gap-2">
|
||
{% csrf_token %}
|
||
<input type="text" name="name" placeholder="New area name…" required
|
||
class="flex-1 bg-white border border-[#F0E4D4] rounded-full px-4 py-2.5
|
||
font-nunito text-sm text-[#1A1208] placeholder-[#C0B0A0]
|
||
outline-none focus:border-[#F07040]">
|
||
<button type="submit" class="bb-fab text-xl font-light flex-shrink-0">+</button>
|
||
</form>
|
||
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5.2: Verify page renders**
|
||
|
||
```bash
|
||
python manage.py runserver 0.0.0.0:8000
|
||
```
|
||
|
||
Open http://localhost:8000/locations/ — expect My Garden screen with area rows and + form.
|
||
|
||
- [ ] **Step 5.3: Commit**
|
||
|
||
```bash
|
||
git add plants/templates/plants/locations.html
|
||
git commit -m "feat: restyle locations.html as My Garden screen with cover photo thumbnails"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: location_detail.html — Area Detail screen
|
||
|
||
**Files:**
|
||
- Create: `plants/templates/plants/location_detail.html`
|
||
|
||
- [ ] **Step 6.1: Create location_detail.html**
|
||
|
||
```html
|
||
{# plants/templates/plants/location_detail.html #}
|
||
{% extends "plants/base.html" %}
|
||
{% block title %}{{ location.name }} — BloomBase{% endblock %}
|
||
{% block content %}
|
||
|
||
<!-- Cover photo hero -->
|
||
<div class="relative h-[180px] overflow-hidden">
|
||
{% if location.cover_photo %}
|
||
<img src="{{ location.cover_photo.url }}" alt="{{ location.name }}"
|
||
class="w-full h-full object-cover">
|
||
{% else %}
|
||
<div class="w-full h-full at-placeholder-1"></div>
|
||
{% endif %}
|
||
<!-- Gradient fade at bottom -->
|
||
<div class="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-white to-transparent"></div>
|
||
<!-- Back button -->
|
||
<a href="{% url 'location_list' %}" class="btn-pill absolute top-3 left-3 no-underline">← Back</a>
|
||
<!-- Edit button -->
|
||
<a href="{% url 'location_edit' location.pk %}" class="btn-pill absolute top-3 right-3 no-underline">Edit</a>
|
||
</div>
|
||
|
||
<div class="px-4 pt-3 pb-4">
|
||
|
||
<!-- Area name + count -->
|
||
<h1 class="font-nunito font-black text-[22px] text-[#1A1208] mb-0.5">{{ location.name }}</h1>
|
||
<p class="font-nunito text-sm font-semibold text-[#B0A090] mb-5">
|
||
{{ plants.count }} plant{{ plants.count|pluralize }}
|
||
</p>
|
||
|
||
<!-- Plant grid -->
|
||
{% if plants %}
|
||
<div class="grid grid-cols-2 gap-3">
|
||
{% for plant in plants %}
|
||
<a href="{% url 'plant_detail' plant.pk %}" class="bg-white rounded-2xl overflow-hidden shadow-sm no-underline block">
|
||
<!-- Plant image -->
|
||
<div class="h-[100px] overflow-hidden">
|
||
{% if plant.species and plant.species.api_image_url %}
|
||
<img src="{{ plant.species.api_image_url }}" alt="{{ plant.name }}"
|
||
class="w-full h-full object-cover">
|
||
{% elif plant.thumbnail_url %}
|
||
<img src="{{ plant.thumbnail_url }}" alt="{{ plant.name }}"
|
||
class="w-full h-full object-cover">
|
||
{% else %}
|
||
<div class="w-full h-full bg-[#D8F3DC] flex items-center justify-center text-3xl">🌿</div>
|
||
{% endif %}
|
||
</div>
|
||
<!-- Plant info -->
|
||
<div class="p-3">
|
||
<div class="font-nunito font-extrabold text-[12px] text-[#1A1208] leading-tight">{{ plant.name }}</div>
|
||
{% if plant.species %}
|
||
<div class="font-nunito text-[9px] font-semibold text-[#B0A090] mt-0.5 italic">{{ plant.species.scientific_name }}</div>
|
||
{% endif %}
|
||
</div>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<!-- Empty state -->
|
||
<div class="text-center py-12">
|
||
<div class="text-5xl mb-3">🪴</div>
|
||
<p class="font-nunito font-bold text-[#B0A090] mb-4">No plants here yet.</p>
|
||
<a href="{% url 'identify_upload' %}" class="btn-bloom inline-block w-auto px-8">Identify a plant</a>
|
||
</div>
|
||
{% endif %}
|
||
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 6.2: Verify page renders**
|
||
|
||
Open http://localhost:8000/locations/1/ (replace 1 with a real pk) — expect hero + plant grid.
|
||
|
||
- [ ] **Step 6.3: Commit**
|
||
|
||
```bash
|
||
git add plants/templates/plants/location_detail.html
|
||
git commit -m "feat: add location_detail.html — area detail with cover photo header + plant grid"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: location_edit.html — add cover photo upload
|
||
|
||
**Files:**
|
||
- Modify: `plants/templates/plants/location_edit.html`
|
||
|
||
- [ ] **Step 7.1: Rewrite location_edit.html**
|
||
|
||
```html
|
||
{# plants/templates/plants/location_edit.html #}
|
||
{% extends "plants/base.html" %}
|
||
{% block title %}Edit Area — BloomBase{% endblock %}
|
||
{% block content %}
|
||
|
||
<div class="px-4 pt-6 pb-4">
|
||
|
||
<div class="flex items-center gap-3 mb-6">
|
||
<a href="{% url 'location_list' %}" class="btn-pill no-underline text-sm">←</a>
|
||
<h1 class="font-nunito font-black text-[20px] text-[#1A1208]">Edit area</h1>
|
||
</div>
|
||
|
||
<form method="post" enctype="multipart/form-data" class="flex flex-col gap-4">
|
||
{% csrf_token %}
|
||
|
||
<!-- Area name -->
|
||
<div>
|
||
<label class="block font-nunito font-bold text-[11px] text-[#B0A090] uppercase tracking-wider mb-1.5">
|
||
Area name
|
||
</label>
|
||
<input type="text" name="name" value="{{ location.name }}" required autofocus
|
||
class="w-full bg-white border border-[#F0E4D4] rounded-2xl px-4 py-3
|
||
font-nunito text-[15px] text-[#1A1208] outline-none focus:border-[#F07040]">
|
||
</div>
|
||
|
||
<!-- Cover photo -->
|
||
<div>
|
||
<label class="block font-nunito font-bold text-[11px] text-[#B0A090] uppercase tracking-wider mb-1.5">
|
||
Cover photo
|
||
</label>
|
||
{% if location.cover_photo %}
|
||
<div class="mb-2 rounded-2xl overflow-hidden h-[100px]">
|
||
<img src="{{ location.cover_photo.url }}" alt="Current cover"
|
||
class="w-full h-full object-cover">
|
||
</div>
|
||
{% endif %}
|
||
<label class="flex items-center gap-3 bg-white border border-[#F0E4D4] rounded-2xl px-4 py-3 cursor-pointer">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#F07040" stroke-width="2">
|
||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||
<circle cx="12" cy="13" r="4"/>
|
||
</svg>
|
||
<span class="font-nunito font-semibold text-sm text-[#B0A090]">
|
||
{% if location.cover_photo %}Replace photo{% else %}Add cover photo{% endif %}
|
||
</span>
|
||
<input type="file" name="cover_photo" accept="image/*" class="hidden" id="cover-input"
|
||
onchange="document.getElementById('cover-label').textContent = this.files[0]?.name || ''">
|
||
</label>
|
||
<span id="cover-label" class="block mt-1 font-nunito text-[10px] text-[#B0A090] px-1"></span>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="flex flex-col gap-2 mt-2">
|
||
<button type="submit" class="btn-bloom">Save</button>
|
||
<a href="{% url 'location_list' %}" class="btn-bloom-outline text-center no-underline">Cancel</a>
|
||
</div>
|
||
</form>
|
||
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 7.2: Verify form submits with photo**
|
||
|
||
Open http://localhost:8000/locations/1/edit/ — fill name, attach image, save. Confirm redirect to /locations/ with updated thumbnail.
|
||
|
||
- [ ] **Step 7.3: Commit**
|
||
|
||
```bash
|
||
git add plants/templates/plants/location_edit.html
|
||
git commit -m "feat: restyle location_edit.html with cover photo upload field"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: dashboard.html — camera-first Home screen
|
||
|
||
Also update `dashboard` view to pass `active_tab`.
|
||
|
||
**Files:**
|
||
- Modify: `plants/views/dashboard.py`
|
||
- Modify: `plants/templates/plants/dashboard.html`
|
||
|
||
- [ ] **Step 8.1: Update dashboard view to pass active_tab and location count**
|
||
|
||
```python
|
||
# plants/views/dashboard.py
|
||
from datetime import date
|
||
from django.shortcuts import render
|
||
from plants.models import Plant, Location
|
||
|
||
|
||
def dashboard(request):
|
||
today = date.today()
|
||
all_plants = list(Plant.objects.select_related('species').all())
|
||
blooming_now = [p for p in all_plants if today.month in (p.bloom_months or [])]
|
||
latest_plants = Plant.objects.select_related('species', 'location').prefetch_related('photos').order_by('-pk')[:6]
|
||
location_count = Location.objects.count()
|
||
|
||
return render(request, 'plants/dashboard.html', {
|
||
'total_plants': len(all_plants),
|
||
'blooming_now': blooming_now,
|
||
'blooming_now_count': len(blooming_now),
|
||
'today': today,
|
||
'latest_plants': latest_plants,
|
||
'location_count': location_count,
|
||
'active_tab': 'home',
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 8.2: Rewrite dashboard.html**
|
||
|
||
```html
|
||
{# plants/templates/plants/dashboard.html #}
|
||
{% extends "plants/base.html" %}
|
||
{% load plant_extras %}
|
||
{% block title %}BloomBase{% endblock %}
|
||
{% block content %}
|
||
|
||
<!-- Top bar: hamburger + bell -->
|
||
<div class="flex justify-between items-center px-4 pt-6 pb-2">
|
||
<button class="p-1" aria-label="Menu">
|
||
<svg width="22" height="22" fill="none" stroke="#1A1208" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M3 12h18M3 6h18M3 18h18"/>
|
||
</svg>
|
||
</button>
|
||
<button class="p-1" aria-label="Notifications">
|
||
<svg width="22" height="22" fill="none" stroke="#1A1208" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="px-4">
|
||
|
||
<!-- Greeting + headline -->
|
||
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-0.5">Hello, Plant Lover! 🌿</p>
|
||
<h1 class="font-nunito font-black text-[24px] text-[#1A1208] leading-tight mb-1">
|
||
What are we <span class="text-[#F07040]">growing</span> today?
|
||
</h1>
|
||
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
|
||
Identify a plant and add it to Bloombase.
|
||
</p>
|
||
|
||
<!-- Camera viewfinder — tap → identify_upload -->
|
||
<a href="{% url 'identify_upload' %}" class="block relative bg-[#2C3820] rounded-2xl h-[120px] overflow-hidden mb-4 no-underline">
|
||
<!-- Simulated plant in viewfinder -->
|
||
<div class="absolute inset-0"
|
||
style="background: radial-gradient(ellipse 60% 55% at 60% 45%, #C84060 0%, #E05878 30%, #F880A0 55%, transparent 75%),
|
||
radial-gradient(ellipse 40% 35% at 40% 55%, #C04050 0%, #D86070 40%, transparent 70%),
|
||
linear-gradient(160deg, #1E3018 0%, #2E5028 30%, #3A6832 50%, #2C5020 70%);"></div>
|
||
<!-- Corner brackets -->
|
||
<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>
|
||
<!-- Shutter + gallery -->
|
||
<div class="absolute bottom-3 left-0 right-0 flex justify-center items-center gap-6">
|
||
<div class="w-8 h-8 bg-white/20 rounded-xl flex items-center justify-center border border-white/40">
|
||
<svg width="14" height="14" 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>
|
||
</div>
|
||
<div class="w-10 h-10 rounded-full bb-fab"></div>
|
||
</div>
|
||
</a>
|
||
|
||
<!-- My Garden pill -->
|
||
<a href="{% url 'location_list' %}"
|
||
class="flex items-center gap-3 bg-white/80 rounded-2xl px-4 py-3 border border-[#F0E4D4] mb-5 no-underline">
|
||
<span class="text-2xl">🪴</span>
|
||
<div class="flex-1">
|
||
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">My Garden</div>
|
||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090]">
|
||
{{ total_plants }} Plant{{ total_plants|pluralize }} · {{ location_count }} Area{{ location_count|pluralize }}
|
||
</div>
|
||
</div>
|
||
<span class="text-[#D0C0B0] font-bold text-lg">›</span>
|
||
</a>
|
||
|
||
<!-- Recently added -->
|
||
{% if latest_plants %}
|
||
<h2 class="font-nunito font-black text-[16px] text-[#1A1208] mb-3">🌿 Recently added</h2>
|
||
<div class="flex flex-col gap-2 mb-4">
|
||
{% for plant in latest_plants %}
|
||
<a href="{% url 'plant_detail' plant.pk %}"
|
||
class="flex items-center gap-3 bg-white rounded-2xl px-3 py-2.5 shadow-sm no-underline">
|
||
{% if plant.species and plant.species.api_image_url %}
|
||
<img src="{{ plant.species.api_image_url }}" width="44" height="44"
|
||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
||
{% elif plant.thumbnail_url %}
|
||
<img src="{{ plant.thumbnail_url }}" width="44" height="44"
|
||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
||
{% else %}
|
||
<div class="w-11 h-11 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-xl">🌿</div>
|
||
{% endif %}
|
||
<div>
|
||
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ plant.name }}</div>
|
||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090]">{{ plant.location }}</div>
|
||
</div>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 8.3: Verify home screen**
|
||
|
||
Open http://localhost:8000/ — expect camera viewfinder + My Garden pill + recent plants.
|
||
|
||
- [ ] **Step 8.4: Commit**
|
||
|
||
```bash
|
||
git add plants/views/dashboard.py plants/templates/plants/dashboard.html
|
||
git commit -m "feat: restyle dashboard as camera-first Home screen"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: plant_list.html — search + location filter chips
|
||
|
||
Also update the `plant_list` view to pass `active_tab`.
|
||
|
||
**Files:**
|
||
- Modify: `plants/views/plants.py` (just add `active_tab` to plant_list context)
|
||
- Modify: `plants/templates/plants/plant_list.html`
|
||
|
||
- [ ] **Step 9.1: Add active_tab to plant_list view**
|
||
|
||
In `plants/views/plants.py`, find the `plant_list` view and add `'active_tab': 'garden'` to the context dict it passes to `render`. (Check the current return statement and add the key alongside existing context keys.)
|
||
|
||
- [ ] **Step 9.2: Rewrite plant_list.html**
|
||
|
||
```html
|
||
{# plants/templates/plants/plant_list.html #}
|
||
{% extends "plants/base.html" %}
|
||
{% block title %}Plants — BloomBase{% endblock %}
|
||
{% block content %}
|
||
|
||
<div class="px-4 pt-6">
|
||
|
||
<!-- Header -->
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h1 class="font-nunito font-black text-[22px] text-[#1A1208]">All Plants</h1>
|
||
<a href="{% url 'plant_add' %}"
|
||
class="bb-fab w-10 h-10 flex items-center justify-center rounded-full no-underline text-xl font-light">+</a>
|
||
</div>
|
||
|
||
<!-- Search -->
|
||
<div class="flex items-center gap-2 bg-[#F5F0EA] rounded-full px-4 py-2.5 mb-3">
|
||
<svg width="16" height="16" fill="none" stroke="#B0A090" stroke-width="2" viewBox="0 0 24 24">
|
||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||
</svg>
|
||
<input
|
||
type="search"
|
||
name="q"
|
||
value="{{ q }}"
|
||
placeholder="Search plants…"
|
||
class="flex-1 bg-transparent font-nunito text-sm font-semibold text-[#1A1208]
|
||
placeholder-[#C0B0A0] outline-none"
|
||
hx-get="{% url 'plant_list' %}"
|
||
hx-trigger="input changed delay:300ms"
|
||
hx-target="#plant-list"
|
||
hx-swap="innerHTML"
|
||
>
|
||
</div>
|
||
|
||
<!-- Location filter chips -->
|
||
<div class="flex gap-2 overflow-x-auto pb-2 mb-4 scrollbar-hide">
|
||
<a href="{% url 'plant_list' %}"
|
||
class="flex-shrink-0 px-4 py-1.5 rounded-full font-nunito font-bold text-[12px]
|
||
{% if not location_filter %}bg-[#F07040] text-white{% else %}bg-white text-[#B0A090] border border-[#F0E4D4]{% endif %}
|
||
no-underline">
|
||
All
|
||
</a>
|
||
{% for loc in locations %}
|
||
<a href="{% url 'plant_list' %}?location={{ loc.pk }}"
|
||
class="flex-shrink-0 px-4 py-1.5 rounded-full font-nunito font-bold text-[12px]
|
||
{% if location_filter == loc.pk|stringformat:'s' %}bg-[#F07040] text-white{% else %}bg-white text-[#B0A090] border border-[#F0E4D4]{% endif %}
|
||
no-underline">
|
||
{{ loc.name }}
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Plant list -->
|
||
<div id="plant-list">
|
||
{% include "plants/partials/plant_list_results.html" %}
|
||
</div>
|
||
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 9.3: Update plant_list_results partial**
|
||
|
||
Open `plants/templates/plants/partials/plant_list_results.html` and check its current content. Update it to use Tailwind card style:
|
||
|
||
```html
|
||
{# plants/templates/plants/partials/plant_list_results.html #}
|
||
{% for plant in plants %}
|
||
<a href="{% url 'plant_detail' plant.pk %}"
|
||
class="flex items-center gap-3 bg-white rounded-2xl px-3 py-2.5 shadow-sm mb-2.5 no-underline block">
|
||
{% if plant.species and plant.species.api_image_url %}
|
||
<img src="{{ plant.species.api_image_url }}" width="52" height="52"
|
||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
||
{% elif plant.thumbnail_url %}
|
||
<img src="{{ plant.thumbnail_url }}" width="52" height="52"
|
||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
||
{% else %}
|
||
<div class="w-[52px] h-[52px] bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-2xl">🌿</div>
|
||
{% endif %}
|
||
<div class="flex-1 min-w-0">
|
||
<div class="font-nunito font-extrabold text-[14px] text-[#1A1208] truncate">{{ plant.name }}</div>
|
||
{% if plant.location %}
|
||
<span class="inline-block font-nunito text-[10px] font-bold px-2 py-0.5 rounded-full bg-[#FFF3E8] text-[#F07040] mt-0.5">
|
||
{{ plant.location.name }}
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
<span class="text-[#D0C0B0] font-bold text-lg">›</span>
|
||
</a>
|
||
{% empty %}
|
||
<p class="font-nunito text-sm text-[#B0A090] text-center py-8">No plants found.</p>
|
||
{% endfor %}
|
||
```
|
||
|
||
- [ ] **Step 9.4: Verify plant list**
|
||
|
||
Open http://localhost:8000/plants/ — expect search + chips + card list.
|
||
|
||
- [ ] **Step 9.5: Commit**
|
||
|
||
```bash
|
||
git add plants/views/plants.py plants/templates/plants/plant_list.html plants/templates/plants/partials/plant_list_results.html
|
||
git commit -m "feat: restyle plant_list with Tailwind — search, filter chips, plant cards"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: plant_detail.html — hero + care + CTA
|
||
|
||
Also add `active_tab` to the `plant_detail` view.
|
||
|
||
**Files:**
|
||
- Modify: `plants/views/plants.py` (add `active_tab` to plant_detail context)
|
||
- Modify: `plants/templates/plants/plant_detail.html`
|
||
|
||
- [ ] **Step 10.1: Add active_tab to plant_detail view context**
|
||
|
||
In `plants/views/plants.py`, find the `plant_detail` view and add `'active_tab': 'garden'` to the context.
|
||
|
||
- [ ] **Step 10.2: Rewrite plant_detail.html**
|
||
|
||
```html
|
||
{# plants/templates/plants/plant_detail.html #}
|
||
{% extends "plants/base.html" %}
|
||
{% load plant_extras %}
|
||
{% block title %}{{ plant.name }} — BloomBase{% endblock %}
|
||
{% block content %}
|
||
|
||
<!-- Hero image -->
|
||
<div class="relative h-[220px] overflow-hidden" id="plant-hero-container">
|
||
{% if plant.species and plant.species.api_image_url %}
|
||
<img src="{{ plant.species.api_image_url }}" alt="{{ plant.name }}" class="w-full h-full object-cover">
|
||
{% elif plant.thumbnail_url %}
|
||
<img src="{{ plant.thumbnail_url }}" alt="{{ plant.name }}" class="w-full h-full object-cover">
|
||
{% else %}
|
||
<div class="w-full h-full bg-[#D8F3DC] flex items-center justify-center text-6xl">🌿</div>
|
||
{% endif %}
|
||
<div class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white to-transparent"></div>
|
||
<!-- Back -->
|
||
<a href="{% url 'plant_list' %}" class="btn-pill absolute top-3 left-3 no-underline">← Back</a>
|
||
<!-- Edit -->
|
||
<a href="{% url 'plant_edit' plant.pk %}" class="btn-pill absolute top-3 right-3 no-underline">Edit</a>
|
||
<!-- Favorite (UI only) -->
|
||
<button class="btn-pill absolute top-3 right-16">🤍</button>
|
||
</div>
|
||
|
||
<div class="px-4 pt-3 pb-4">
|
||
|
||
<!-- Plant name + latin -->
|
||
<h1 class="font-nunito font-black text-[22px] text-[#1A1208] mb-0.5">{{ plant.name }}</h1>
|
||
{% if plant.species %}
|
||
<div class="flex items-center gap-1.5 mb-3">
|
||
<span class="font-nunito font-bold text-[13px] text-[#F07040] italic">{{ plant.species.scientific_name }}</span>
|
||
<a href="https://www.google.com/search?q={{ plant.species.scientific_name|default:plant.name|urlencode }}"
|
||
target="_blank" rel="noopener">
|
||
<img src="https://www.google.com/favicon.ico" width="13" height="13" style="opacity:0.65;vertical-align:middle;">
|
||
</a>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if plant.location %}
|
||
<p class="font-nunito text-[11px] font-semibold text-[#B0A090] mb-3">
|
||
📍 {{ plant.location.name }}
|
||
</p>
|
||
{% endif %}
|
||
|
||
<!-- Care row -->
|
||
{% if plant.watering or plant.sunlight or plant.species %}
|
||
<div class="flex justify-around py-3 border-t border-b border-[#F5EDE4] mb-4">
|
||
{% if plant.watering %}
|
||
<div class="flex flex-col items-center gap-1">
|
||
<span class="text-xl">💧</span>
|
||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.get_watering_display }}</span>
|
||
</div>
|
||
{% elif plant.species and plant.species.watering %}
|
||
<div class="flex flex-col items-center gap-1">
|
||
<span class="text-xl">💧</span>
|
||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.watering }}</span>
|
||
</div>
|
||
{% endif %}
|
||
{% if plant.sunlight %}
|
||
<div class="flex flex-col items-center gap-1">
|
||
<span class="text-xl">☀️</span>
|
||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.get_sunlight_display }}</span>
|
||
</div>
|
||
{% elif plant.species and plant.species.sunlight %}
|
||
<div class="flex flex-col items-center gap-1">
|
||
<span class="text-xl">☀️</span>
|
||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.sunlight }}</span>
|
||
</div>
|
||
{% endif %}
|
||
{% if plant.species and plant.species.frost_hardiness_c is not None %}
|
||
<div class="flex flex-col items-center gap-1">
|
||
<span class="text-xl">🌡️</span>
|
||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.frost_hardiness_c }}°C min</span>
|
||
</div>
|
||
{% endif %}
|
||
{% if plant.species and plant.species.max_height_cm %}
|
||
<div class="flex flex-col items-center gap-1">
|
||
<span class="text-xl">📏</span>
|
||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.max_height_cm }} cm</span>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Bloom strip -->
|
||
{% if plant.bloom_months %}
|
||
<div class="mb-4 p-3 rounded-2xl bg-[#D8F3DC]">
|
||
<p class="font-nunito font-bold text-[11px] text-[#1b4332] mb-1">🌸 Bloom months</p>
|
||
{% month_strip plant.bloom_months %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Notes -->
|
||
{% if plant.notes %}
|
||
<div class="bg-white rounded-2xl px-4 py-3 border border-[#F0E4D4] mb-4">
|
||
<p class="font-nunito text-[13px] text-[#6B7280]">{{ plant.notes|linebreaksbr }}</p>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Pruning -->
|
||
{% if plant.pruning_months %}
|
||
{% include "plants/partials/pruning_strip.html" %}
|
||
{% endif %}
|
||
|
||
<!-- Photo gallery -->
|
||
<div id="photo-gallery" class="mb-4">
|
||
{% include "plants/partials/photo_gallery.html" %}
|
||
</div>
|
||
|
||
<!-- Action buttons -->
|
||
<div class="flex flex-col gap-2 mt-2">
|
||
<a href="{% url 'plant_edit' plant.pk %}" class="btn-bloom no-underline">Edit this plant</a>
|
||
<a href="{% url 'plant_card' plant.pk %}" class="btn-bloom-outline no-underline">🪧 Plant card</a>
|
||
<a href="{% url 'plant_delete' plant.pk %}"
|
||
class="font-nunito font-bold text-[13px] text-center text-[#DC2626] py-2 no-underline">
|
||
Delete plant
|
||
</a>
|
||
</div>
|
||
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 10.3: Verify plant detail**
|
||
|
||
Open http://localhost:8000/plants/1/ — expect hero + care row + CTA buttons.
|
||
|
||
- [ ] **Step 10.4: Commit**
|
||
|
||
```bash
|
||
git add plants/views/plants.py plants/templates/plants/plant_detail.html
|
||
git commit -m "feat: restyle plant_detail — hero, care row, gradient CTA buttons"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: identify_upload.html — viewfinder camera UI
|
||
|
||
Also add `active_tab` to `identify_upload` view.
|
||
|
||
**Files:**
|
||
- Modify: `plants/views/identify.py` (add `active_tab`)
|
||
- Modify: `plants/templates/plants/identify_upload.html`
|
||
|
||
- [ ] **Step 11.1: Add active_tab to identify_upload view**
|
||
|
||
In `plants/views/identify.py`, find the `identify_upload` view. Add `'active_tab': 'home'` to the context dict passed to render (for the GET response).
|
||
|
||
- [ ] **Step 11.2: Rewrite identify_upload.html**
|
||
|
||
```html
|
||
{# plants/templates/plants/identify_upload.html #}
|
||
{% extends "plants/base.html" %}
|
||
{% block title %}Identify plant — BloomBase{% endblock %}
|
||
{% block content %}
|
||
|
||
<div class="px-4 pt-6">
|
||
|
||
<!-- 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>
|
||
|
||
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
|
||
Add one or more photos, tag what each shows, then tap Identify.
|
||
</p>
|
||
|
||
{% if error %}
|
||
<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 %}
|
||
|
||
<form method="post" enctype="multipart/form-data" id="identify-form">
|
||
{% csrf_token %}
|
||
<input type="file" name="images" accept="image/jpeg,image/png" class="hidden" id="file-picker">
|
||
|
||
<!-- 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>
|
||
|
||
<!-- Photo rows (populated by JS) -->
|
||
<div id="photo-rows" class="mb-4"></div>
|
||
|
||
<!-- 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>
|
||
</div>
|
||
|
||
<button type="submit" id="identify-btn" disabled
|
||
class="btn-bloom opacity-50 cursor-not-allowed" id="identify-btn">
|
||
Identify →
|
||
</button>
|
||
</form>
|
||
|
||
<div class="text-center mt-4">
|
||
<a href="{% url 'plant_add' %}" class="font-nunito text-sm font-semibold text-[#B0A090] no-underline">
|
||
Or enter name manually
|
||
</a>
|
||
</div>
|
||
|
||
<!-- Loading overlay -->
|
||
<div id="identify-spinner"
|
||
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>
|
||
|
||
<script>
|
||
const ORGANS = [
|
||
{val:'flower',label:'🌸 flower'},{val:'leaf',label:'🍃 leaf'},
|
||
{val:'habit',label:'🌿 habit'},{val:'fruit',label:'🍎 fruit'},
|
||
{val:'bark',label:'🪵 bark'},{val:'auto',label:'✨ auto'},
|
||
];
|
||
const selected = [];
|
||
const picker = document.getElementById('file-picker');
|
||
|
||
picker.addEventListener('change', function () {
|
||
if (!this.files[0] || selected.length >= 5) return;
|
||
selected.push({file: this.files[0], organ: 'auto', objectUrl: URL.createObjectURL(this.files[0])});
|
||
this.value = '';
|
||
render();
|
||
});
|
||
|
||
function setOrgan(idx, organ) { selected[idx].organ = organ; render(); }
|
||
|
||
function removePhoto(idx) {
|
||
URL.revokeObjectURL(selected[idx].objectUrl);
|
||
selected.splice(idx, 1);
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
const rows = document.getElementById('photo-rows');
|
||
rows.innerHTML = '';
|
||
selected.forEach((item, i) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'flex gap-3 items-start mb-4';
|
||
row.innerHTML = `
|
||
<div style="position:relative;flex-shrink:0;">
|
||
<img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:14px;">
|
||
<button type="button" onclick="removePhoto(${i})"
|
||
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 style="flex:1;">
|
||
<div style="font-family:Nunito,sans-serif;font-size:11px;font-weight:700;color:#B0A090;margin-bottom:6px;">What's shown?</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:5px;">
|
||
${ORGANS.map(o=>`
|
||
<span onclick="setOrgan(${i},'${o.val}')"
|
||
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}
|
||
</span>`).join('')}
|
||
</div>
|
||
<input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
|
||
</div>`;
|
||
rows.appendChild(row);
|
||
});
|
||
|
||
document.querySelectorAll('input[name="organs"]').forEach((el,i)=>{
|
||
if(selected[i]) el.value=selected[i].organ;
|
||
});
|
||
|
||
document.getElementById('photo-count').textContent = selected.length ? `(${selected.length}/5)` : '';
|
||
document.getElementById('add-btn-wrap').style.display = selected.length >= 5 ? 'none' : '';
|
||
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) {
|
||
if (selected.length === 0) { e.preventDefault(); return; }
|
||
const dt = new DataTransfer();
|
||
selected.forEach(s => dt.items.add(s.file));
|
||
picker.files = dt.files;
|
||
document.getElementById('identify-spinner').style.display = 'flex';
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 11.3: Verify identify screen**
|
||
|
||
Open http://localhost:8000/identify/ — expect viewfinder box + shutter button + organ tags.
|
||
|
||
- [ ] **Step 11.4: Commit**
|
||
|
||
```bash
|
||
git add plants/views/identify.py plants/templates/plants/identify_upload.html
|
||
git commit -m "feat: restyle identify_upload as camera-first viewfinder screen"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: identify_confirm.html — match list restyle
|
||
|
||
**Files:**
|
||
- Modify: `plants/templates/plants/identify_confirm.html`
|
||
|
||
- [ ] **Step 12.1: Rewrite identify_confirm.html**
|
||
|
||
```html
|
||
{# plants/templates/plants/identify_confirm.html #}
|
||
{% extends "plants/base.html" %}
|
||
{% block title %}Confirm species — BloomBase{% endblock %}
|
||
{% block content %}
|
||
|
||
<div class="px-4 pt-6">
|
||
|
||
<div class="flex items-center gap-3 mb-2">
|
||
<a href="{% url 'identify_upload' %}" class="btn-pill no-underline text-sm">←</a>
|
||
<h1 class="font-nunito font-black text-[20px] text-[#1A1208]">Is this your plant?</h1>
|
||
</div>
|
||
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
|
||
Tap the correct species, then confirm.
|
||
</p>
|
||
|
||
<form method="post" id="confirm-form">
|
||
{% csrf_token %}
|
||
<input type="hidden" name="match_idx" id="match_idx_input" value="0">
|
||
|
||
<div class="flex flex-col gap-3 mb-5" id="match-list">
|
||
{% for m in matches %}
|
||
<div class="match-card flex items-center gap-3 bg-white rounded-2xl p-3 shadow-sm cursor-pointer
|
||
{% if forloop.first %}ring-2 ring-[#F07040]{% endif %}"
|
||
data-idx="{{ forloop.counter0 }}"
|
||
data-name="{{ m.scientific_name }}"
|
||
onclick="selectMatch(this)">
|
||
{% if m.image_url %}
|
||
<img src="{{ m.image_url }}" alt="{{ m.scientific_name }}"
|
||
class="w-16 h-16 rounded-xl flex-shrink-0 object-cover">
|
||
{% else %}
|
||
<div class="w-16 h-16 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-2xl">🌿</div>
|
||
{% endif %}
|
||
<div class="flex-1 min-w-0">
|
||
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ m.scientific_name }}</div>
|
||
{% if m.common_names %}
|
||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] mt-0.5 truncate">
|
||
{{ m.common_names|join:", " }}
|
||
</div>
|
||
{% endif %}
|
||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] italic">{{ m.family }}</div>
|
||
</div>
|
||
<span class="font-nunito font-bold text-[11px] px-2 py-1 rounded-full flex-shrink-0
|
||
{% if m.score >= 0.70 %}bg-green-100 text-green-700
|
||
{% elif m.score >= 0.30 %}bg-orange-100 text-orange-700
|
||
{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||
{% widthratio m.score 1 100 %}%
|
||
</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<button type="submit" class="btn-bloom">
|
||
Use <span id="selected-name">{{ matches.0.scientific_name }}</span> →
|
||
</button>
|
||
</form>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
function selectMatch(el) {
|
||
document.querySelectorAll('.match-card').forEach(c => c.classList.remove('ring-2','ring-[#F07040]'));
|
||
el.classList.add('ring-2','ring-[#F07040]');
|
||
document.getElementById('match_idx_input').value = el.dataset.idx;
|
||
document.getElementById('selected-name').textContent = el.dataset.name;
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 12.2: Verify identify confirm**
|
||
|
||
Run a real identification or view the confirm page directly — expect match cards with confidence badges and gradient confirm button.
|
||
|
||
- [ ] **Step 12.3: Commit**
|
||
|
||
```bash
|
||
git add plants/templates/plants/identify_confirm.html
|
||
git commit -m "feat: restyle identify_confirm — match cards with confidence badges"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage check:**
|
||
|
||
| Spec item | Task |
|
||
|---|---|
|
||
| Replace Bootstrap 5 with Tailwind CSS | Task 2 (base.html) |
|
||
| 5-tab bottom nav (Home, Garden, +, Care, Profile) | Task 2 |
|
||
| `Location.cover_photo` + migration | Task 3 |
|
||
| `LocationForm` with cover_photo | Task 4 |
|
||
| `location_detail` view + URL | Task 4 |
|
||
| locations.html My Garden screen | Task 5 |
|
||
| location_detail.html Area Detail | Task 6 |
|
||
| location_edit.html with cover photo | Task 7 |
|
||
| dashboard.html camera-first | Task 8 |
|
||
| plant_list.html | Task 9 |
|
||
| plant_detail.html | Task 10 |
|
||
| identify_upload.html | Task 11 |
|
||
| identify_confirm.html | Task 12 |
|
||
| Canvas blob decorations + SVG flowers | Task 1 + Task 2 |
|
||
| Great Vibes + Nunito + Pacifico fonts | Task 1 |
|
||
| Gradient CTA buttons | Task 1 (.btn-bloom) |
|
||
| `active_tab` nav active state | Tasks 4, 8, 9, 10, 11 |
|
||
| Care + Profile stubs | Task 2 (href="#") |
|
||
|
||
**Gaps identified and resolved:**
|
||
- `plant_list_results.html` partial was missing — added in Task 9.3
|
||
- `plant_list` and `plant_detail` views need `active_tab` in context — noted in Tasks 9 and 10
|
||
- `identify_upload` view needs `active_tab` — noted in Task 11
|
||
|
||
**Type/name consistency:**
|
||
- `active_tab` values: `'home'` (dashboard, identify), `'garden'` (locations, plant_list, plant_detail, location_detail, location_edit)
|
||
- CSS class names used consistently: `.btn-bloom`, `.btn-bloom-outline`, `.btn-pill`, `.bb-fab`, `.vf-corner .vf-tl/tr/bl/br`
|
||
- `at-placeholder-N` classes 1–6 defined in bloombase.css and used in locations.html + location_detail.html
|
||
|
||
**No placeholders:** All template code is complete. All CSS is complete.
|