Compare commits

..

No commits in common. "b9a84a41c51d4e5bfd1599221a8b256b5d4478fd" and "c1d0f0a3464c7a9644088d911ca45fcb637963b7" have entirely different histories.

27 changed files with 461 additions and 2902 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,200 +0,0 @@
# BloomBase UI Redesign — Design Spec
**Date:** 2026-05-31
**Project:** plantdb (BloomBase)
**Primary user:** Naomi (plant owner, photographer, Dutch Farmhouse content creator)
**Inspired by:** Naomi's own design mockup — vibrant organic poster aesthetic
---
## 1. Scope
Full visual redesign of the BloomBase Django app, replacing Bootstrap 5 with Tailwind CSS. New bottom navigation. Location model extended with cover photo. All existing screens restyled. One new screen (Area Detail).
**In scope:**
- Replace Bootstrap 5 with Tailwind CSS (CDN play/JIT acceptable; production: CLI build)
- Rewrite `base.html` with bottom navigation
- Restyle all existing templates: dashboard, plant list, plant detail, locations, identify flow
- New Area Detail page (cover photo header + plant grid)
- `Location.cover_photo` ImageField + migration + upload UI
**Out of scope:**
- Care tab new features (stub tab only)
- Profile tab (stub tab only)
- Authentication / multi-user
---
## 2. Visual System
### Color Palette
| Role | Value |
|---|---|
| Page background | `#FFF8F2` (warm cream) |
| Primary / CTA | `#F07040` coral-orange |
| CTA gradient | `linear-gradient(135deg, #F58040, #E84020)` |
| Accent / nature | `#52B788` green |
| Dark text | `#1A1208` |
| Muted text | `#B0A090` |
| Card background | `#FFFFFF` |
| Card border / subtle | `#F0E4D4` |
| Tint background | `#FFF8F2` |
### Typography
| Usage | Font | Weight | Size |
|---|---|---|---|
| Brand name "Bloombase" | Great Vibes (approximates Timeless) | 400 | 3246px |
| Page headlines | Nunito | 900 | 2128px |
| Section titles | Nunito | 800 | 1619px |
| Body / labels | Nunito | 600700 | 912px |
| Tagline / CTA sub | Pacifico | 400 | 1416px |
Google Fonts import:
```
Nunito:wght@400;600;700;800;900 | Great Vibes | Pacifico
```
Note: "Timeless" (Naomi's preferred font) is a commercial script font. Great Vibes is the closest free equivalent; swap for Timeless font file if provided.
### Decorative Elements
- Page canvas: two fixed-position CSS blob shapes (coral top-left, amber bottom-right) at ~20% opacity
- Canvas SVG flowers: hibiscus (top-right), daisy (bottom-right), small pink flower (mid-left), leaf cluster (bottom-left) — fixed-position, pointer-events: none
- These decorations live on the **page canvas only**, never inside phone/card content areas
- Card interiors: clean white, no decorations
---
## 3. Navigation
Bottom navigation bar — 5 tabs, height 62px, stays within `overflow: hidden`.
| Tab | Icon | Active state | Target |
|---|---|---|---|
| Home | house SVG | coral fill | `/` |
| My Garden | circle/plant SVG | coral stroke | `/locations/` |
| + (FAB) | gradient circle 44px | always orange | `/identify/` |
| Care | mic/leaf SVG | coral stroke | Stub (future) |
| Profile | person SVG | coral stroke | Stub (future) |
FAB: `background: linear-gradient(145deg, #F58040, #E84020)`, `box-shadow: 0 4px 16px rgba(240,80,40,0.5)`, sits **within** nav bar height (no negative margin needed).
---
## 4. Screens
### 4.1 Home (dashboard.html)
Camera-first layout. Replaces existing stats cards.
- Top bar: hamburger icon (left) + bell icon (right)
- Greeting: "Hello, Plant Lover! 🌿" (small, muted)
- Headline: "What are we **growing** today?" (Nunito 900, "growing" in coral)
- Sub: "Identify a plant and add it to Bloombase."
- Camera viewfinder: dark rounded card (~114px tall), corner brackets, shutter button (gradient) + gallery button
- My Garden pill: icon + "My Garden" + plant/area count + chevron → links to `/locations/`
- Bottom nav: Home tab active
### 4.2 My Garden (locations.html)
List of areas/locations.
- Header: "My Garden" (Nunito 900) + "Edit" link (coral, right)
- Search bar: pill shape, gray tint background
- Area rows: cover photo thumbnail (68×60px, `object-fit: cover`) + area name + plant count + chevron
- If no cover photo: CSS gradient placeholder (each area gets a different hue)
- No extra + FAB on this screen — + in bottom nav handles adding
- Bottom nav: My Garden tab active
### 4.3 Area Detail (new: `/locations/<id>/`)
- Cover photo header: full-width, ~180px tall, `object-fit: cover`, gradient overlay at bottom
- Back button (top-left), edit button (top-right) — white pill buttons
- Area name + plant count below hero
- Plant grid: 2 columns, white cards, plant photo thumbnail + common name + species
- Empty state: friendly message + link to identify
- Bottom nav: My Garden tab active
### 4.4 Plant Detail (plant_detail.html)
- Hero image: full-width top section
- Back + favorite buttons overlaid (white pill)
- Plant name (Nunito 900) + Latin name (coral)
- Match confidence bar (if from identification)
- Care icons row: light · water · temp
- Action buttons: "Add to my garden" (gradient CTA) + "Save to identify later" (outline)
- Photo gallery below fold
### 4.5 Identify / Camera (identify flow)
- Viewfinder as primary element
- Gallery picker button + shutter button
- Post-identification: Plant Detail screen (#4.4) with confidence bar visible
### 4.6 Plant List (plant_list.html)
- Search bar at top
- Location filter chips (horizontal scroll, coral when active)
- Plant cards: thumbnail + name + area tag
- Matches vibrant palette
---
## 5. Data Model Change
**`Location` model** — add one field:
```python
cover_photo = models.ImageField(upload_to='locations/', blank=True, null=True)
```
Migration: `python manage.py makemigrations && migrate`
**Location edit/create form:** add `cover_photo` file field. Display current photo with replace option. Use same upload pattern as plant photos (multipart form).
---
## 6. Implementation Approach
### Tailwind Setup
Use Tailwind CSS CDN (Play CDN) for development: one `<script>` tag in `base.html`. For production build: add `tailwindcss` to npm devDependencies, generate `output.css`, serve as static file.
### Template Strategy
- `base.html`: remove Bootstrap, add Tailwind CDN, add bottom nav, add page blob/flower decorations
- All other templates: inherit from new `base.html`, rewrite content blocks using Tailwind utility classes
- Keep HTMX — no changes to behavior
- Keep Django form handling — only CSS changes for form widgets
### Custom CSS
Small `bloombase.css` for:
- Great Vibes / Nunito font imports
- CSS blob shapes (can't trivially do in Tailwind)
- SVG canvas flowers (fixed-position)
- Phone-specific patterns if needed in mockup/splash
### Static Files
- `/static/css/bloombase.css` — custom styles
- `/static/images/` — no new images needed (all CSS/SVG)
---
## 7. File Change Summary
| File | Change |
|---|---|
| `plants/models.py` | Add `cover_photo` to `Location` |
| `plants/migrations/XXXX_add_location_cover_photo.py` | New migration |
| `plants/views.py` | Location edit/create views handle `cover_photo` |
| `plants/forms.py` | `LocationForm` includes `cover_photo` |
| `plants/templates/plants/base.html` | Full rewrite: Tailwind, bottom nav, decorations |
| `plants/templates/plants/dashboard.html` | Camera-first home |
| `plants/templates/plants/plant_list.html` | Tailwind restyle |
| `plants/templates/plants/plant_detail.html` | Tailwind restyle |
| `plants/templates/plants/locations.html` | My Garden with cover photos |
| `plants/templates/plants/location_detail.html` | **New** — area detail page |
| `plants/templates/plants/location_form.html` | Add cover photo upload |
| `static/css/bloombase.css` | **New** — fonts, blobs, canvas flowers |
| `plants/urls.py` | Add route for `location_detail` |
---
## 8. Out-of-Scope Notes
- Care tab: render a stub "Coming soon" page
- Profile tab: render a stub or redirect to plant stats
- Dark mode: not in scope
- PWA / offline: not in scope
- Multi-user / auth: not in scope

View file

@ -71,9 +71,3 @@ class PruningLogForm(forms.ModelForm):
'pruned_on': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'pruned_on': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}), 'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}),
} }
class LocationForm(forms.ModelForm):
class Meta:
model = Location
fields = ['name', 'cover_photo']

View file

@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2026-06-01 14:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('plants', '0010_add_frost_density_to_species'),
]
operations = [
migrations.AddField(
model_name='location',
name='cover_photo',
field=models.ImageField(blank=True, null=True, upload_to='locations/'),
),
]

View file

@ -3,7 +3,6 @@ from django.db import models
class Location(models.Model): class Location(models.Model):
name = models.CharField(max_length=200, unique=True) name = models.CharField(max_length=200, unique=True)
cover_photo = models.ImageField(upload_to='locations/', blank=True, null=True)
class Meta: class Meta:
ordering = ['name'] ordering = ['name']

View file

@ -1,110 +0,0 @@
/* 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; }

View file

@ -1,161 +1,44 @@
{# plants/templates/plants/base.html #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>{% block title %}BloomBase{% endblock %}</title> <title>{% block title %}BloomBase{% endblock %}</title>
{% load static %} <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<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> <script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
{% block extra_head %}{% endblock %} <style>
body { padding-bottom: 1rem; }
.plant-hero { width: 100%; height: 220px; object-fit: cover; }
.plant-hero-placeholder { width: 100%; height: 220px; background: #d8f3dc; display: flex; align-items: center; justify-content: center; color: #52b788; font-size: 3rem; }
.care-chip { display: inline-block; padding: .2rem .55rem; border-radius: 1rem; font-size: .8rem; margin: .15rem; }
.pruning-strip { border-left: 4px solid #ffc107; background: #fff9c4; padding: .5rem .75rem; border-radius: 0 .375rem .375rem 0; }
.pruning-strip.overdue { border-color: #dc3545; background: #fff5f5; }
/* Month strip — display */
.month-strip { display: flex; gap: 3px; }
.month-dot { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; font-size: 10px; font-weight: 700; letter-spacing: 0; background: #e9ecef; color: #adb5bd; }
.month-dot--on { background: #2d6a4f; color: #fff; }
/* Month toggle — form */
.month-toggle-group { display: flex; flex-wrap: wrap; gap: 5px; }
.month-toggle { position: relative; }
.month-toggle input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
.month-toggle label { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; font-size: 13px; font-weight: 700; cursor: pointer; background: #e9ecef; color: #6c757d; transition: background .15s, color .15s; user-select: none; }
.month-toggle input:checked + label { background: #2d6a4f; color: #fff; }
</style>
</head> </head>
<body class="bg-[#FFF8F2]"> <body>
<nav class="navbar navbar-dark px-3" style="background-color: #2d6a4f;">
<!-- Canvas background blobs --> <a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌸 BloomBase</a>
<div class="bb-blob-tl"></div> <div class="d-flex gap-2">
<div class="bb-blob-br"></div> <a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a>
<a href="{% url 'add_plant_from_scan' %}" class="btn btn-sm btn-outline-light" title="Add plant by card scan">🪪</a>
<!-- Canvas SVG flowers --> <a href="{% url 'identify_upload' %}" class="btn btn-sm btn-light fw-bold">+</a>
<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"> </div>
<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-24 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> </nav>
<div class="container-fluid px-3 pt-3">
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>

View file

@ -1,95 +1,71 @@
{# plants/templates/plants/dashboard.html #}
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% load plant_extras %} {% load plant_extras %}
{% block title %}BloomBase{% endblock %} {% block title %}Dashboard — BloomBase{% endblock %}
{% block content %} {% block content %}
<h6 class="text-muted mb-3">{{ today|date:"F Y" }}</h6>
<!-- Top bar: hamburger + bell --> <div class="row g-2 mb-3">
<div class="flex justify-between items-center px-4 pt-6 pb-2"> <div class="col-6">
<button class="p-1" aria-label="Menu"> <a href="{% url 'plant_list' %}" class="text-decoration-none">
<svg width="22" height="22" fill="none" stroke="#1A1208" stroke-width="2" viewBox="0 0 24 24"> <div class="card text-center h-100" style="background:#d8f3dc;">
<path d="M3 12h18M3 6h18M3 18h18"/> <div class="card-body py-3">
</svg> <div class="display-5 fw-bold" style="color:#1b4332;">{{ total_plants }}</div>
</button> <div class="small" style="color:#2d6a4f;">Plants</div>
<button class="p-1" aria-label="Notifications"> </div>
<svg width="22" height="22" fill="none" stroke="#1A1208" stroke-width="2" viewBox="0 0 24 24"> </div>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/> </a>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/> </div>
</svg> <div class="col-6">
</button> <div class="card text-center h-100" style="background:#d8f3dc;">
<div class="card-body py-3">
<div class="display-5 fw-bold" style="color:#1b4332;">{{ blooming_now_count }}</div>
<div class="small" style="color:#2d6a4f;">Blooming now</div>
</div>
</div>
</div>
</div> </div>
<div class="px-4"> {% if blooming_now %}
<div class="card mb-3">
<!-- Greeting + headline --> <div class="card-header fw-semibold">🌸 Blooming this month</div>
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-0.5">Hello, Plant Lover! 🌿</p> <div class="list-group list-group-flush">
<h1 class="font-nunito font-black text-[24px] text-[#1A1208] leading-tight mb-1"> {% for plant in blooming_now %}
What are we <span class="text-[#F07040]">growing</span> today? <a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
</h1> <div>
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4"> <div class="fw-semibold">{{ plant.name }}</div>
Identify a plant and add it to Bloombase. <small class="text-muted">{{ plant.location }}</small>
</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> </div>
<div>{% month_strip plant.bloom_months %}</div>
</a> </a>
{% endfor %}
<!-- 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>
</div> </div>
<span class="text-[#D0C0B0] font-bold text-lg"></span> {% endif %}
</a>
<!-- Recently added --> {% if latest_plants %}
{% if latest_plants %} <div class="card mb-3">
<h2 class="font-nunito font-black text-[16px] text-[#1A1208] mb-3">🌿 Recently added</h2> <div class="card-header fw-semibold">🌿 Recently added</div>
<div class="flex flex-col gap-2 mb-4"> <div class="list-group list-group-flush">
{% for plant in latest_plants %} {% for plant in latest_plants %}
<a href="{% url 'plant_detail' plant.pk %}" <a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2 text-decoration-none">
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 %} {% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" width="44" height="44" <img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover;flex-shrink:0;">
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% elif plant.thumbnail_url %} {% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" width="44" height="44" <img src="{{ plant.thumbnail_url }}" width="48" height="48" class="rounded" style="object-fit:cover;flex-shrink:0;">
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% else %} {% else %}
<div class="w-11 h-11 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-xl">🌿</div> <div style="width:48px;height:48px;background:#d8f3dc;border-radius:.375rem;flex-shrink:0;display:flex;align-items:center;justify-content:center;">🌿</div>
{% endif %} {% endif %}
<div> <div>
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ plant.name }}</div> <div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div>
<div class="font-nunito text-[10px] font-semibold text-[#B0A090]">{{ plant.location }}</div> <small class="text-muted">{{ plant.location }}</small>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} </div>
{% endif %}
<div class="d-grid">
<a href="{% url 'plant_add' %}" class="btn btn-success">+ Add a plant</a>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,109 +1,62 @@
{# plants/templates/plants/identify_confirm.html #}
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% block title %}Confirm species — BloomBase{% endblock %} {% block title %}Confirm species — BloomBase{% endblock %}
{% block content %} {% block content %}
<div class="px-4 pt-6"> <div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'identify_upload' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0">Is this your plant?</h5>
</div>
<div class="flex items-center gap-3 mb-2"> <p class="text-muted small mb-3">Tap the correct species, then confirm.</p>
<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"> <form method="post" id="confirm-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="match_idx" id="match_idx_input" value="0"> <input type="hidden" name="match_idx" id="match_idx_input" value="0">
<div class="flex flex-col gap-3 mb-5" id="match-list"> <div class="d-flex flex-column gap-2 mb-4" id="match-list">
{% for m in matches %} {% for m in matches %}
<div class="match-card flex items-center gap-3 bg-white rounded-2xl p-3 shadow-sm cursor-pointer <div class="match-card d-flex align-items-center gap-3 p-3 border rounded"
{% if forloop.first %}ring-2 ring-[#F07040]{% endif %}" style="cursor:pointer; {% if forloop.first %}border-color:#52b788 !important; background:#f0faf4;{% endif %}"
data-idx="{{ forloop.counter0 }}" data-idx="{{ forloop.counter0 }}"
data-name="{{ m.scientific_name }}" data-name="{{ m.scientific_name }}"
onclick="selectMatch(this)"> onclick="selectMatch(this)">
{% if m.image_url %} {% if m.image_url %}
<img src="{{ m.image_url }}" alt="{{ m.scientific_name }}" <img src="{{ m.image_url }}" alt="{{ m.scientific_name }}"
class="w-16 h-16 rounded-xl flex-shrink-0 object-cover"> style="width:64px;height:64px;object-fit:cover;border-radius:8px;flex-shrink:0;">
{% else %} {% else %}
<div class="w-16 h-16 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-2xl">🌿</div> <div style="width:64px;height:64px;background:#e9ecef;border-radius:8px;flex-shrink:0;"></div>
{% endif %} {% endif %}
<div class="flex-1 min-w-0"> <div class="flex-grow-1">
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ m.scientific_name }}</div> <div class="fw-bold">{{ m.scientific_name }}</div>
{% if m.common_names %} {% if m.common_names %}
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] mt-0.5 truncate"> <div class="text-muted small">{{ m.common_names|join:", " }}</div>
{{ m.common_names|join:", " }}
</div>
{% endif %} {% endif %}
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] italic">{{ m.family }}</div> <div class="text-muted small fst-italic">{{ m.family }}</div>
</div> </div>
<span class="font-nunito font-bold text-[11px] px-2 py-1 rounded-full flex-shrink-0 <div>
{% if m.score >= 0.70 %}bg-green-100 text-green-700 <span class="badge {% if m.score >= 0.70 %}bg-success{% elif m.score >= 0.30 %}bg-warning text-dark{% else %}bg-secondary{% endif %}">
{% elif m.score >= 0.30 %}bg-orange-100 text-orange-700
{% else %}bg-gray-100 text-gray-500{% endif %}">
{% widthratio m.score 1 100 %}% {% widthratio m.score 1 100 %}%
</span> </span>
</div> </div>
</div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Plant name override --> <button type="submit" class="btn btn-success w-100" id="confirm-btn">
<div class="mb-4"> Use <span id="selected-name">{{ matches.0.scientific_name }}</span>
<label class="font-nunito font-bold text-[11px] text-[#B0A090] block mb-1">Plant name (optional)</label>
<input type="text" name="plant_name" id="plant-name-input"
class="w-full 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]"
placeholder="Leave blank to use species name">
</div>
<!-- Location picker -->
{% if locations %}
<div class="mb-5">
<label class="font-nunito font-bold text-[11px] text-[#B0A090] block mb-2">Add to area</label>
<div class="flex flex-wrap gap-2">
<input type="hidden" name="location_id" id="location-id-input" value="">
{% for loc in locations %}
<span class="location-chip font-nunito font-bold text-[11px] px-3 py-1.5 rounded-full
bg-[#F5F0EA] text-[#B0A090] cursor-pointer"
data-id="{{ loc.pk }}"
onclick="selectLocation(this)">
{{ loc.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
<button type="submit" class="btn-bloom">
Add to my garden →
</button> </button>
</form> </form>
</div>
<script> <script>
function selectMatch(card) { function selectMatch(el) {
document.querySelectorAll('.match-card').forEach(c => c.classList.remove('ring-2', 'ring-[#F07040]')); document.querySelectorAll('.match-card').forEach(c => {
card.classList.add('ring-2', 'ring-[#F07040]'); c.style.borderColor = '';
document.getElementById('match_idx_input').value = card.dataset.idx; c.style.background = '';
const nameInput = document.getElementById('plant-name-input');
if (!nameInput.value || nameInput.dataset.autoSet) {
nameInput.value = card.dataset.name;
nameInput.dataset.autoSet = '1';
}
}
function selectLocation(chip) {
document.querySelectorAll('.location-chip').forEach(c => {
c.classList.remove('bg-[#F07040]', 'text-white');
c.classList.add('bg-[#F5F0EA]', 'text-[#B0A090]');
}); });
chip.classList.remove('bg-[#F5F0EA]', 'text-[#B0A090]'); el.style.borderColor = '#52b788';
chip.classList.add('bg-[#F07040]', 'text-white'); el.style.background = '#f0faf4';
document.getElementById('location-id-input').value = chip.dataset.id; document.getElementById('match_idx_input').value = el.dataset.idx;
document.getElementById('selected-name').textContent = el.dataset.name;
} }
</script> </script>
{% endblock %} {% endblock %}

View file

@ -17,16 +17,21 @@
<div class="text-muted" style="font-size:11px;">vasteplantencatalogus.nl</div> <div class="text-muted" style="font-size:11px;">vasteplantencatalogus.nl</div>
</div> </div>
{% if vpc_results|length > 1 %} {% if vpc_results|length > 1 %}
<form method="get" action="{% url 'identify_fields' %}"> <div class="dropdown">
<select name="vpc_idx" onchange="this.form.submit()" <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
class="w-full bg-white border border-[#F0E4D4] rounded-full px-4 py-2.5 font-nunito text-sm text-[#1A1208] outline-none focus:border-[#F07040]"> change
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for r in vpc_results %} {% for r in vpc_results %}
<option value="{{ forloop.counter0 }}" {% if forloop.counter0 == vpc_idx %}selected{% endif %}> <li>
<a class="dropdown-item {% if forloop.counter0 == vpc_idx %}active{% endif %}"
href="{% url 'identify_fields' %}?vpc_idx={{ forloop.counter0 }}">
{{ r.common_name }} {{ r.common_name }}
</option> </a>
</li>
{% endfor %} {% endfor %}
</select> </ul>
</form> </div>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}

View file

@ -1,96 +1,56 @@
{# 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="px-4 pt-6"> <div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0">Identify plant</h5>
</div>
<!-- Header --> <p class="text-muted small mb-3">
<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. Add one or more photos, tag what each shows, then tap Identify.
</p> </p>
{% if error %} {% 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"> <div class="alert alert-warning py-2 small mb-3">{{ error }}</div>
{{ error }} {% endif %}
</div>
{% 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="hidden" id="file-picker"> <input type="file" name="images" accept="image/jpeg,image/png" class="d-none" id="file-picker">
<!-- Viewfinder / drop zone --> <div id="photo-rows" class="mb-3"></div>
<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="add-btn-wrap" class="mb-3">
<div id="photo-rows" class="mb-4"></div> <label class="btn btn-outline-secondary w-100 py-3" for="file-picker" id="add-photo-label">
📷 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" id="identify-btn" disabled <button type="submit" class="btn btn-success w-100" id="identify-btn" disabled>
class="btn-bloom opacity-50 cursor-not-allowed">
Identify → Identify →
</button> </button>
</form> </form>
<div class="text-center mt-4"> <div class="text-center mt-3">
<a href="{% url 'plant_add' %}" class="font-nunito text-sm font-semibold text-[#B0A090] no-underline"> <a href="{% url 'plant_add' %}" class="text-muted small">Or enter name manually</a>
Or enter name manually </div>
</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 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;">
<div class="spinner-border text-success" role="status"><span class="visually-hidden">Identifying…</span></div>
<p class="text-muted small mb-0">Identifying plant…</p>
</div> </div>
<script> <script>
const ORGANS = [ const ORGANS = [
{val:'flower',label:'🌸 flower'},{val:'leaf',label:'🍃 leaf'}, {val: 'flower', label: '🌸 flower'},
{val:'habit',label:'🌿 habit'},{val:'fruit',label:'🍎 fruit'}, {val: 'leaf', label: '🍃 leaf'},
{val:'bark',label:'🪵 bark'},{val:'auto',label:'✨ auto'}, {val: 'habit', label: '🌿 habit'},
{val: 'fruit', label: '🍎 fruit'},
{val: 'bark', label: '🪵 bark'},
{val: 'auto', label: '✨ auto'},
]; ];
const selected = []; const selected = []; // {file, organ, objectUrl}
const picker = document.getElementById('file-picker'); const picker = document.getElementById('file-picker');
picker.addEventListener('change', function () { picker.addEventListener('change', function () {
@ -100,7 +60,10 @@ picker.addEventListener('change', function () {
render(); render();
}); });
function setOrgan(idx, organ) { selected[idx].organ = organ; render(); } function setOrgan(idx, organ) {
selected[idx].organ = organ;
render();
}
function removePhoto(idx) { function removePhoto(idx) {
URL.revokeObjectURL(selected[idx].objectUrl); URL.revokeObjectURL(selected[idx].objectUrl);
@ -113,43 +76,38 @@ 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 = 'flex gap-3 items-start mb-4'; row.className = 'd-flex gap-2 align-items-start mb-3';
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:14px;"> <img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:8px;" alt="">
<button type="button" onclick="removePhoto(${i})" <button type="button" onclick="removePhoto(${i})"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.5);color:#fff; 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>
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 style="font-family:Nunito,sans-serif;font-size:11px;font-weight:700;color:#B0A090;margin-bottom:6px;">What's shown?</div> <div class="text-muted small mb-1">What's shown?</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;"> <div class="d-flex flex-wrap gap-1">
${ORGANS.map(o=>` ${ORGANS.map(o => `
<span onclick="setOrgan(${i},'${o.val}')" <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; style="cursor:pointer; padding:2px 8px; border-radius:10px; font-size:11px; ${item.organ === o.val ? 'background:#52b788;color:#fff;' : 'background:#e9ecef;color:#6c757d;'}">
${item.organ===o.val?'background:#F07040;color:#fff;':'background:#F5F0EA;color:#B0A090;'}">
${o.label} ${o.label}
</span>`).join('')} </span>
`).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>`;
rows.appendChild(row); rows.appendChild(row);
}); });
document.querySelectorAll('input[name="organs"]').forEach((el,i)=>{ document.querySelectorAll('input[name="organs"]').forEach((el, i) => {
if(selected[i]) el.value=selected[i].organ; if (selected[i]) el.value = selected[i].organ;
}); });
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' : '';
const btn = document.getElementById('identify-btn'); document.getElementById('identify-btn').disabled = selected.length === 0;
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) {
if (selected.length === 0) { e.preventDefault(); return; } if (selected.length === 0) { e.preventDefault(); return; }
const dt = new DataTransfer(); const dt = new DataTransfer();
selected.forEach(s => dt.items.add(s.file)); selected.forEach(s => dt.items.add(s.file));

View file

@ -1,67 +0,0 @@
{# 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 %}

View file

@ -1,59 +1,21 @@
{# plants/templates/plants/location_edit.html #}
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% block title %}Edit Area — BloomBase{% endblock %} {% block title %}Edit Area — BloomBase{% endblock %}
{% block content %} {% block content %}
<div class="px-4 pt-6 pb-4"> <div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'location_list' %}" class="btn btn-sm btn-outline-secondary"></a>
<div class="flex items-center gap-3 mb-6"> <h5 class="mb-0 flex-grow-1">Edit area</h5>
<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> </div>
<form method="post">
{% csrf_token %}
<div class="mb-3">
<input type="text" name="name" value="{{ location.name }}" class="form-control" required autofocus>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">Save</button>
<a href="{% url 'location_list' %}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
{% endblock %} {% endblock %}

View file

@ -1,72 +1,36 @@
{# plants/templates/plants/locations.html #}
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% block title %}My Garden — BloomBase{% endblock %} {% block title %}Areas — BloomBase{% endblock %}
{% block content %} {% 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 class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0 flex-grow-1">Areas</h5>
</div> </div>
<div class="list-group mb-3">
{% for loc in locations %}
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
<span>{{ loc.name }} <small class="text-muted">{{ loc.plant_count }} plant{{ loc.plant_count|pluralize }}</small></span>
<div class="d-flex gap-1">
<a href="{% url 'location_edit' loc.pk %}" class="btn btn-sm btn-outline-secondary py-0 px-2">✏️</a>
<form method="post" action="{% url 'location_delete' loc.pk %}"
onsubmit="return {% if loc.plant_count %}confirm('{{ loc.name|escapejs }} has {{ loc.plant_count }} plant{{ loc.plant_count|pluralize }}. Their area will be cleared. Delete anyway?'){% else %}true{% endif %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-2"></button>
</form>
</div>
</div>
{% empty %}
<div class="list-group-item text-muted">No areas yet.</div>
{% endfor %}
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex gap-2">
<input type="text" name="name" class="form-control form-control-sm" placeholder="New area name" required>
<button type="submit" class="btn btn-sm btn-success text-nowrap">+ Add</button>
</div>
</form>
{% endblock %} {% endblock %}

View file

@ -1,35 +1,30 @@
<div class="mb-4"> <div class="mb-3">
<h6 class="font-nunito font-bold text-sm text-[#1A1208] mb-2">Photos</h6> <h6 class="mb-2">Photos</h6>
{% with photos=plant.photos.all %} {% with photos=plant.photos.all %}
{% if photos %} {% if photos %}
<div class="flex flex-wrap gap-2 mb-3"> <div class="d-flex flex-wrap gap-2 mb-2">
{% for photo in photos %} {% for photo in photos %}
<div class="relative"> <div class="position-relative">
<img src="{{ photo.image.url }}" width="80" height="80" <img src="{{ photo.image.url }}" width="80" height="80"
class="rounded-lg object-cover {% if photo.is_thumbnail %}ring-2 ring-[#52B788]{% endif %}" class="rounded {% if photo.is_thumbnail %}border border-success border-2{% endif %}"
style="object-fit:cover;" alt=""> style="object-fit:cover;" alt="">
<div class="flex gap-1 mt-1"> <div class="d-flex gap-1 mt-1">
{% if not photo.is_thumbnail %} {% if not photo.is_thumbnail %}
<form method="post" action="{% url 'set_thumbnail' photo.pk %}" <form method="post" action="{% url 'set_thumbnail' photo.pk %}"
hx-post="{% url 'set_thumbnail' photo.pk %}" hx-post="{% url 'set_thumbnail' photo.pk %}"
hx-target="#photo-gallery" hx-swap="innerHTML"> hx-target="#photo-gallery" hx-swap="innerHTML">
{% csrf_token %} {% csrf_token %}
<button type="submit" <button type="submit" class="btn btn-outline-success btn-sm py-0 px-1" style="font-size:.7rem;" title="Set as thumbnail"></button>
class="inline-flex items-center justify-center w-6 h-6 rounded text-[#52B788] border border-[#52B788] hover:bg-[#E8F4EC] text-xs leading-none"
title="Set as thumbnail">★</button>
</form> </form>
{% else %} {% else %}
<span class="inline-flex items-center justify-center w-6 h-6 rounded bg-[#52B788] text-white text-xs leading-none cursor-default" <span class="btn btn-success btn-sm py-0 px-1 disabled" style="font-size:.7rem;" title="Thumbnail"></span>
title="Thumbnail">★</span>
{% endif %} {% endif %}
<form method="post" action="{% url 'delete_photo' photo.pk %}" <form method="post" action="{% url 'delete_photo' photo.pk %}"
hx-post="{% url 'delete_photo' photo.pk %}" hx-post="{% url 'delete_photo' photo.pk %}"
hx-target="#photo-gallery" hx-swap="innerHTML"> hx-target="#photo-gallery" hx-swap="innerHTML">
{% csrf_token %} {% csrf_token %}
<button type="submit" <button type="submit" class="btn btn-outline-danger btn-sm py-0 px-1" style="font-size:.7rem;" title="Delete"></button>
class="inline-flex items-center justify-center w-6 h-6 rounded text-[#F07040] border border-[#F07040] hover:bg-orange-50 text-xs leading-none"
title="Delete">✕</button>
</form> </form>
</div> </div>
</div> </div>
@ -42,13 +37,13 @@
hx-post="{% url 'upload_photo' plant.pk %}" hx-target="#photo-gallery" hx-swap="innerHTML" hx-post="{% url 'upload_photo' plant.pk %}" hx-target="#photo-gallery" hx-swap="innerHTML"
hx-encoding="multipart/form-data" hx-indicator="#photo-upload-spinner"> hx-encoding="multipart/form-data" hx-indicator="#photo-upload-spinner">
{% csrf_token %} {% csrf_token %}
<div class="flex gap-2 items-center flex-wrap"> <div class="d-flex gap-2 align-items-center flex-wrap">
<label class="btn-bloom-outline text-xs px-3 py-1 cursor-pointer" for="photo-upload-input">📷 Choose photo</label> <label class="btn btn-sm btn-outline-secondary mb-0" for="photo-upload-input">📷 Choose photo</label>
<input type="file" id="photo-upload-input" name="image" accept="image/*" class="hidden" <input type="file" id="photo-upload-input" name="image" accept="image/*" class="d-none"
onchange="document.getElementById('photo-filename').textContent = this.files[0]?.name || ''"> onchange="document.getElementById('photo-filename').textContent = this.files[0]?.name || ''">
<span id="photo-filename" class="text-xs text-[#B0A090]"></span> <span id="photo-filename" class="text-muted small"></span>
<button type="submit" class="btn-bloom-outline text-xs px-3 py-1">Upload</button> <button type="submit" class="btn btn-sm btn-outline-secondary">Upload</button>
<span id="photo-upload-spinner" class="htmx-indicator text-xs text-[#B0A090]">Uploading…</span> <span id="photo-upload-spinner" class="htmx-indicator text-muted small">Uploading…</span>
</div> </div>
</form> </form>
</div> </div>

View file

@ -1,26 +1,23 @@
{# plants/templates/plants/partials/plant_list_results.html #}
{% for plant in plants %} {% for plant in plants %}
<a href="{% url 'plant_detail' plant.pk %}" <a href="{% url 'plant_detail' plant.pk %}" class="text-decoration-none">
class="flex items-center gap-3 bg-white rounded-2xl px-3 py-2.5 shadow-sm mb-2.5 no-underline block"> <div class="card mb-2">
<div class="card-body d-flex align-items-center gap-3 py-2">
{% if plant.species and plant.species.api_image_url %} {% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" width="52" height="52" <img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% elif plant.thumbnail_url %} {% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" width="52" height="52" <img src="{{ plant.thumbnail_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% else %} {% else %}
<div class="w-[52px] h-[52px] bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-2xl">🌿</div> <div style="width:48px; height:48px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</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 %} {% endif %}
<div>
<div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div>
<small class="text-muted">
{{ plant.location }}
</small>
</div>
</div>
</div> </div>
<span class="text-[#D0C0B0] font-bold text-lg"></span>
</a> </a>
{% empty %} {% empty %}
<p class="font-nunito text-sm text-[#B0A090] text-center py-8">No plants found.</p> <p class="text-muted text-center mt-4">No plants found.</p>
{% endfor %} {% endfor %}

View file

@ -1,22 +1,22 @@
{% load plant_extras %} {% load plant_extras %}
<div id="pruning-strip" class="pruning-strip {% if pruning_status == 'overdue' %}overdue{% endif %} mb-4 p-3 rounded-2xl bg-[#E8F4EC]"> <div id="pruning-strip" class="pruning-strip {% if pruning_status == 'overdue' %}overdue{% endif %} mb-3 rounded-end">
<div class="flex justify-between items-start gap-2"> <div class="d-flex justify-content-between align-items-start gap-2">
<div> <div>
<p class="font-nunito font-bold text-[11px] text-[#1b4332] mb-1">✂️ Pruning months</p> <strong>✂️ Pruning</strong>
{% if plant.pruning_months %} {% if plant.pruning_months %}
<div class="mt-1">{% month_strip plant.pruning_months %}</div> <div class="mt-1">{% month_strip plant.pruning_months %}</div>
{% endif %} {% endif %}
{% with plant.pruning_logs.first as last_log %} {% with plant.pruning_logs.first as last_log %}
{% if last_log %} {% if last_log %}
<div class="text-xs text-[#B0A090] mt-1 whitespace-nowrap">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div> <div class="small text-muted mt-1 text-nowrap">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>
<div class="shrink-0"> <div class="flex-shrink-0">
{% if pruning_status == 'overdue' %} {% if pruning_status == 'overdue' %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-red-100 text-red-700">Overdue</span> <span class="badge bg-danger">Overdue</span>
{% elif pruning_status == 'due_this_month' %} {% elif pruning_status == 'due_this_month' %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-yellow-100 text-yellow-800">Due this month</span> <span class="badge bg-warning text-dark">Due this month</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -28,9 +28,9 @@
class="mt-2" class="mt-2"
> >
{% csrf_token %} {% csrf_token %}
<div class="flex gap-2 items-end flex-wrap"> <div class="d-flex gap-2 align-items-end flex-wrap">
{{ log_form.pruned_on }} {{ log_form.pruned_on }}
<button type="submit" class="btn-bloom-outline text-xs px-3 py-1">Log pruning</button> <button type="submit" class="btn btn-sm btn-success">Log pruning</button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -1,131 +1,109 @@
{# plants/templates/plants/plant_detail.html #}
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% load plant_extras %}
{% block title %}{{ plant.name }} — BloomBase{% endblock %} {% block title %}{{ plant.name }} — BloomBase{% endblock %}
{% block content %} {% block content %}
<!-- Hero image --> <div class="d-flex align-items-center gap-2 mb-2">
<div class="relative h-[220px] overflow-hidden" id="plant-hero-container"> <a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a>
{% if plant.species and plant.species.api_image_url %} <h5 class="mb-0 flex-grow-1">{{ plant.name }}</h5>
<img src="{{ plant.species.api_image_url }}" alt="{{ plant.name }}" class="w-full h-full object-cover"> <a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a>
{% 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>
<div class="px-4 pt-3 pb-4"> <div id="plant-hero-container">
{% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" class="plant-hero rounded mb-3" alt="{{ plant.species.common_name }}">
{% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
{% else %}
<div class="plant-hero-placeholder rounded mb-3">🌿</div>
{% endif %}
</div>
<!-- Plant name + latin --> <div class="d-flex justify-content-between align-items-start mb-1">
<h1 class="font-nunito font-black text-[22px] text-[#1A1208] mb-0.5">{{ plant.name }}</h1> <div>
{% if plant.species %} <h6 class="mb-0">{{ plant.name }}</h6>
<div class="flex items-center gap-1.5 mb-3"> <div class="d-flex align-items-center gap-1">
<span class="font-nunito font-bold text-[13px] text-[#F07040] italic">{{ plant.species.scientific_name }}</span> {% if plant.species %}<small class="text-muted fst-italic">{{ plant.species.scientific_name }}</small>{% endif %}
<a href="https://www.google.com/search?q={{ plant.species.scientific_name|default:plant.name|urlencode }}" <a href="https://www.google.com/search?q={{ plant.species.scientific_name|default:plant.name|urlencode }}" target="_blank" rel="noopener" title="Search on Google">
target="_blank" rel="noopener"> <img src="https://www.google.com/favicon.ico" width="14" height="14" style="opacity:0.7;vertical-align:middle;">
<img src="https://www.google.com/favicon.ico" width="13" height="13" style="opacity:0.65;vertical-align:middle;">
</a> </a>
</div> </div>
{% endif %} </div>
<span class="badge {% if plant.is_indoor %}bg-info{% else %}bg-success{% endif %}">
{% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %}
</span>
</div>
{% if plant.location %} {% if plant.location %}<p class="text-muted small mb-2">📍 {{ plant.location.name }}</p>{% endif %}
<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 %}
{% if plant.watering or plant.sunlight or plant.species %} <div class="mb-3">
<div class="flex justify-around py-3 border-t border-b border-[#F5EDE4] mb-4">
{% if plant.watering %} {% if plant.watering %}
<div class="flex flex-col items-center gap-1"> <span class="care-chip" style="background:#e3f2fd; color:#1565c0;">{{ plant.get_watering_display }}</span>
<span class="text-xl">💧</span> {% elif plant.species.watering %}
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.get_watering_display }}</span> <span class="care-chip" style="background:#e3f2fd; color:#1565c0;">💧 {{ plant.species.watering }}</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 %} {% endif %}
{% if plant.sunlight %} {% if plant.sunlight %}
<div class="flex flex-col items-center gap-1"> <span class="care-chip" style="background:#fff9c4; color:#7d4a00;">{{ plant.get_sunlight_display }}</span>
<span class="text-xl">☀️</span> {% elif plant.species.sunlight %}
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.get_sunlight_display }}</span> <span class="care-chip" style="background:#fff9c4; color:#7d4a00;">☀️ {{ plant.species.sunlight }}</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 %} {% endif %}
{% if plant.species and plant.species.frost_hardiness_c is not None %} {% if plant.species.max_height_cm %}
<div class="flex flex-col items-center gap-1"> <span class="care-chip" style="background:#f3e5f5; color:#4a148c;">📏 up to {{ plant.species.max_height_cm }} cm</span>
<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 %} {% endif %}
{% if plant.species and plant.species.max_height_cm %} {% if plant.species.frost_hardiness_c is not None %}
<div class="flex flex-col items-center gap-1"> <span class="care-chip" style="background:#e3f2fd; color:#0d47a1;">❄️ {{ plant.species.frost_hardiness_c }}°C</span>
<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 %} {% endif %}
</div> {% if plant.species.planting_density_m2 %}
<span class="care-chip" style="background:#f9fbe7; color:#558b2f;">🌿 {{ plant.species.planting_density_m2 }}/m²</span>
{% endif %} {% endif %}
{% if plant.species.growth_rate %}
<!-- Bloom strip --> <span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span>
{% 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 %} {% 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 %}
<!-- Card photos (crop thumbnails) -->
{% if plant.card_photos.all %}
<div class="mb-4">
<p class="font-nunito font-bold text-[11px] text-[#B0A090] mb-2">🪧 Plant card photos</p>
<a href="{% url 'crop_thumbnail' plant.pk %}" class="btn-bloom-outline no-underline">
Edit card photo
</a>
</div>
{% 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> </div>
{% endif %}
{% if plant.species and plant.species.vpc_slug %}
<div class="mb-3">
<form method="post" action="{% url 'vpc_species_reset' plant.species.pk %}">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'plant_detail' plant.pk %}">
<button type="submit" class="btn btn-sm btn-outline-secondary"
onclick="return confirm('Reset species data to original VPC values?')">
↩ Reset to original VPC data
</button>
</form>
</div>
{% endif %}
{% load plant_extras %}
{% if plant.bloom_months %}
<div class="mb-3 p-2 rounded" style="background:#d8f3dc; border-left:4px solid #52b788;">
<strong>🌸 Bloom</strong>
<div class="mt-1">{% month_strip plant.bloom_months %}</div>
</div>
{% endif %}
{% if plant.notes %}
<div class="card mb-3">
<div class="card-body py-2">
<small class="text-muted">{{ plant.notes|linebreaksbr }}</small>
</div>
</div>
{% endif %}
{% if plant.pruning_months %}
{% include "plants/partials/pruning_strip.html" %}
{% endif %}
<div id="photo-gallery">
{% include "plants/partials/photo_gallery.html" %}
</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary">🪧 Plant card</a>
{% if plant.card_photos.all %}
<a href="{% url 'crop_thumbnail' plant.pk %}" class="btn btn-sm btn-outline-secondary">✂️ Crop thumbnail</a>
{% endif %}
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-sm btn-outline-danger">Delete plant</a>
</div>
{% endblock %} {% endblock %}

View file

@ -1,58 +1,34 @@
{# plants/templates/plants/plant_list.html #}
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% block title %}Plants — BloomBase{% endblock %} {% block title %}Plants — BloomBase{% endblock %}
{% block content %} {% block content %}
<div class="d-grid mb-3">
<div class="px-4 pt-6"> <a href="{% url 'plant_add' %}" class="btn btn-success">+ Add a plant</a>
</div>
<!-- Header --> <div class="mb-3">
<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 <input
type="search" type="search"
name="q" name="q"
value="{{ q }}" value="{{ q }}"
placeholder="Search plants…" class="form-control"
class="flex-1 bg-transparent font-nunito text-sm font-semibold text-[#1A1208] placeholder="🔍 Search plants..."
placeholder-[#C0B0A0] outline-none"
hx-get="{% url 'plant_list' %}" hx-get="{% url 'plant_list' %}"
hx-trigger="input changed delay:300ms" hx-trigger="input changed delay:300ms"
hx-target="#plant-list" hx-target="#plant-list"
hx-swap="innerHTML" hx-swap="innerHTML"
> >
</div> </div>
<!-- Location filter chips --> <div class="d-flex align-items-center gap-2 mb-3">
<div class="flex gap-2 overflow-x-auto pb-2 mb-4 scrollbar-hide"> <div class="btn-group flex-wrap flex-grow-1" role="group">
<a href="{% url 'plant_list' %}" <a href="{% url 'plant_list' %}" class="btn btn-sm {% if not location_filter %}btn-success{% else %}btn-outline-success{% endif %}">All</a>
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 %} {% for loc in locations %}
<a href="{% url 'plant_list' %}?location={{ loc.pk }}" <a href="{% url 'plant_list' %}?location={{ loc.pk }}" class="btn btn-sm {% if location_filter == loc.pk|stringformat:'s' %}btn-success{% else %}btn-outline-success{% endif %}">{{ loc.name }}</a>
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 %} {% endfor %}
</div> </div>
<a href="{% url 'location_list' %}" class="btn btn-sm btn-outline-secondary flex-shrink-0" title="Manage areas">⚙️</a>
</div>
<!-- Plant list --> <div id="plant-list">
<div id="plant-list">
{% include "plants/partials/plant_list_results.html" %} {% include "plants/partials/plant_list_results.html" %}
</div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,51 +0,0 @@
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from plants.models import Location
from django.test import Client
from django.urls import reverse
@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/')
@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):
import io
from PIL import Image
loc = Location.objects.create(name='Balcony')
buf = io.BytesIO()
Image.new('RGB', (1, 1), color=(0, 255, 0)).save(buf, format='JPEG')
buf.seek(0)
fake_img = SimpleUploadedFile('cover.jpg', buf.read(), 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/')

View file

@ -62,6 +62,14 @@ class TestPlantList:
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.templates[0].name == 'plants/partials/plant_list_results.html' assert resp.templates[0].name == 'plants/partials/plant_list_results.html'
def test_indoor_filter(self, client):
Plant.objects.create(name='Fern', location=make_location('Office'), is_indoor=True)
Plant.objects.create(name='Rose', location=make_location('Garden'), is_indoor=False)
resp = client.get(reverse('plant_list') + '?filter=indoor')
content = resp.content.decode()
assert 'Fern' in content
assert 'Rose' not in content
@pytest.mark.django_db @pytest.mark.django_db
class TestPlantDetail: class TestPlantDetail:

View file

@ -23,7 +23,6 @@ urlpatterns = [
path('photos/<int:photo_pk>/delete/', plants.delete_photo, name='delete_photo'), path('photos/<int:photo_pk>/delete/', plants.delete_photo, name='delete_photo'),
path('pruning/', pruning.pruning_calendar, name='pruning_calendar'), path('pruning/', pruning.pruning_calendar, name='pruning_calendar'),
path('locations/', locations.location_list, name='location_list'), 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>/edit/', locations.location_edit, name='location_edit'),
path('locations/<int:pk>/delete/', locations.location_delete, name='location_delete'), path('locations/<int:pk>/delete/', locations.location_delete, name='location_delete'),
path('species/search/', species.species_search, name='species_search'), path('species/search/', species.species_search, name='species_search'),

View file

@ -1,7 +1,6 @@
# plants/views/dashboard.py
from datetime import date from datetime import date
from django.shortcuts import render from django.shortcuts import render
from plants.models import Plant, Location from plants.models import Plant
def dashboard(request): def dashboard(request):
@ -9,7 +8,6 @@ def dashboard(request):
all_plants = list(Plant.objects.select_related('species').all()) 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 [])] 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] 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', { return render(request, 'plants/dashboard.html', {
'total_plants': len(all_plants), 'total_plants': len(all_plants),
@ -17,6 +15,4 @@ def dashboard(request):
'blooming_now_count': len(blooming_now), 'blooming_now_count': len(blooming_now),
'today': today, 'today': today,
'latest_plants': latest_plants, 'latest_plants': latest_plants,
'location_count': location_count,
'active_tab': 'home',
}) })

View file

@ -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, Location from plants.models import Plant, PlantPhoto, Species
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', {'active_tab': 'home'}) return render(request, 'plants/identify_upload.html')
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.', 'active_tab': 'home'}) {'error': 'Please select at least one photo.'})
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, 'active_tab': 'home'}) return render(request, 'plants/identify_upload.html', {'error': error_msg})
request.session['identify_matches'] = matches request.session['identify_matches'] = matches
request.session['identify_image_paths'] = saved_paths request.session['identify_image_paths'] = saved_paths
@ -100,11 +100,7 @@ def identify_confirm(request):
return redirect('identify_fields') return redirect('identify_fields')
locations = list(Location.objects.all()) return render(request, 'plants/identify_confirm.html', {'matches': matches})
return render(request, 'plants/identify_confirm.html', {
'matches': matches,
'locations': locations,
})
def _apply_vpc_care_fields(species, vpc_result): def _apply_vpc_care_fields(species, vpc_result):

View file

@ -2,7 +2,6 @@ from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.db.models import Count from django.db.models import Count
from plants.models import Location, Plant from plants.models import Location, Plant
from plants.forms import LocationForm
def location_list(request): def location_list(request):
@ -13,36 +12,18 @@ def location_list(request):
return redirect('location_list') return redirect('location_list')
locations = Location.objects.annotate(plant_count=Count('plants')) locations = Location.objects.annotate(plant_count=Count('plants'))
return render(request, 'plants/locations.html', { return render(request, 'plants/locations.html', {'locations': locations})
'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): def location_edit(request, pk):
location = get_object_or_404(Location, pk=pk) location = get_object_or_404(Location, pk=pk)
if request.method == 'POST': if request.method == 'POST':
form = LocationForm(request.POST, request.FILES, instance=location) name = request.POST.get('name', '').strip()
if form.is_valid(): if name:
form.save() location.name = name
location.save()
return redirect('location_list') return redirect('location_list')
else: return render(request, 'plants/location_edit.html', {'location': location})
form = LocationForm(instance=location)
return render(request, 'plants/location_edit.html', {
'location': location,
'form': form,
'active_tab': 'garden',
})
@require_POST @require_POST

View file

@ -27,7 +27,6 @@ def plant_list(request):
'plants': qs, 'q': q, 'plants': qs, 'q': q,
'location_filter': location_filter, 'location_filter': location_filter,
'locations': locations, 'locations': locations,
'active_tab': 'garden',
}) })
@ -42,7 +41,6 @@ def plant_detail(request, pk):
'pruning_status': pruning_status(plant, today), 'pruning_status': pruning_status(plant, today),
'log_form': PruningLogForm(initial={'pruned_on': today}), 'log_form': PruningLogForm(initial={'pruned_on': today}),
'today': today, 'today': today,
'active_tab': 'garden',
}) })

View file

@ -1,2 +1,2 @@
[pytest] [pytest]
DJANGO_SETTINGS_MODULE = bloombase.settings DJANGO_SETTINGS_MODULE = plantdb.settings