Compare commits

..

16 commits

Author SHA1 Message Date
b9a84a41c5 fix: restore card photo section in plant_detail, fix identify_fields dropdown, remove stale filter test
- plant_detail.html: add card photos section with crop_thumbnail link (above photo gallery), satisfying test_card_button_shown_when_card_photos_exist
- identify_fields.html: replace Bootstrap data-bs-toggle dropdown with plain HTML <select> + onchange form submit, styled with Tailwind
- test_views.py: remove test_indoor_filter which tested ?filter=indoor that no longer exists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 18:06:56 +02:00
4e7655bf46 feat: restyle identify_confirm — match cards with confidence badges, location chips 2026-06-01 17:54:17 +02:00
2c45a6f2ee feat: restyle identify_upload as camera-first viewfinder screen 2026-06-01 17:53:33 +02:00
4cac6cd0ce feat: restyle pruning_strip and photo_gallery partials — Bootstrap to Tailwind 2026-06-01 17:37:20 +02:00
78f5f3b54a feat: restyle plant_detail — hero, care row, gradient CTA buttons 2026-06-01 17:34:04 +02:00
cbd58fd2dc feat: restyle plant_list with Tailwind — search, filter chips, plant cards 2026-06-01 17:26:14 +02:00
d7cb9ea25d feat: restyle dashboard as camera-first Home screen 2026-06-01 17:24:23 +02:00
b320b1a471 feat: restyle location_edit.html with cover photo upload field 2026-06-01 16:47:12 +02:00
3149206c3e feat: add location_detail.html — area detail with cover photo header + plant grid 2026-06-01 16:46:24 +02:00
c9905851c2 feat: restyle locations.html as My Garden screen with cover photo thumbnails 2026-06-01 16:45:29 +02:00
e699ddb62d feat: add LocationForm with cover_photo, location_detail view, update location_edit 2026-06-01 16:44:05 +02:00
2d7ca494bd feat: add Location.cover_photo ImageField + migration + test 2026-06-01 16:40:59 +02:00
ea627ad4ef feat: rewrite base.html — Tailwind CDN, 5-tab bottom nav, canvas decorations 2026-06-01 16:39:20 +02:00
69837d88b2 feat: add bloombase.css — fonts, blobs, canvas flowers, shared components 2026-06-01 16:36:23 +02:00
29fd14c06e docs: add BloomBase redesign implementation plan 2026-05-31 21:09:58 +02:00
ff4ff7645c docs: add BloomBase UI redesign spec
Covers visual system (Tailwind, Great Vibes/Timeless font, coral palette),
5-tab bottom nav, all screen designs, Location.cover_photo model change,
and file change summary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 20:59:47 +02:00
27 changed files with 2901 additions and 460 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,200 @@
# 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,3 +71,9 @@ 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

@ -0,0 +1,18 @@
# 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,6 +3,7 @@ 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

@ -0,0 +1,110 @@
/* 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,44 +1,161 @@
{# 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>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"> {% 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> <script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
<style> {% block extra_head %}{% endblock %}
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> <body class="bg-[#FFF8F2]">
<nav class="navbar navbar-dark px-3" style="background-color: #2d6a4f;">
<a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌸 BloomBase</a> <!-- Canvas background blobs -->
<div class="d-flex gap-2"> <div class="bb-blob-tl"></div>
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a> <div class="bb-blob-br"></div>
<a href="{% url 'add_plant_from_scan' %}" class="btn btn-sm btn-outline-light" title="Add plant by card scan">🪪</a>
<a href="{% url 'identify_upload' %}" class="btn btn-sm btn-light fw-bold">+</a> <!-- Canvas SVG flowers -->
</div> <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-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,71 +1,95 @@
{# plants/templates/plants/dashboard.html #}
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% load plant_extras %} {% load plant_extras %}
{% block title %}Dashboard — BloomBase{% endblock %} {% block title %}BloomBase{% endblock %}
{% block content %} {% block content %}
<h6 class="text-muted mb-3">{{ today|date:"F Y" }}</h6>
<div class="row g-2 mb-3"> <!-- Top bar: hamburger + bell -->
<div class="col-6"> <div class="flex justify-between items-center px-4 pt-6 pb-2">
<a href="{% url 'plant_list' %}" class="text-decoration-none"> <button class="p-1" aria-label="Menu">
<div class="card text-center h-100" style="background:#d8f3dc;"> <svg width="22" height="22" fill="none" stroke="#1A1208" stroke-width="2" viewBox="0 0 24 24">
<div class="card-body py-3"> <path d="M3 12h18M3 6h18M3 18h18"/>
<div class="display-5 fw-bold" style="color:#1b4332;">{{ total_plants }}</div> </svg>
<div class="small" style="color:#2d6a4f;">Plants</div> </button>
</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">
</a> <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
</div> <path d="M13.73 21a2 2 0 0 1-3.46 0"/>
<div class="col-6"> </svg>
<div class="card text-center h-100" style="background:#d8f3dc;"> </button>
<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>
{% if blooming_now %} <div class="px-4">
<div class="card mb-3">
<div class="card-header fw-semibold">🌸 Blooming this month</div>
<div class="list-group list-group-flush">
{% for plant in blooming_now %}
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ plant.name }}</div>
<small class="text-muted">{{ plant.location }}</small>
</div>
<div>{% month_strip plant.bloom_months %}</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if latest_plants %} <!-- Greeting + headline -->
<div class="card mb-3"> <p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-0.5">Hello, Plant Lover! 🌿</p>
<div class="card-header fw-semibold">🌿 Recently added</div> <h1 class="font-nunito font-black text-[24px] text-[#1A1208] leading-tight mb-1">
<div class="list-group list-group-flush"> 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 %} {% for plant in latest_plants %}
<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"> <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 %} {% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover;flex-shrink:0;"> <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 %} {% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" width="48" height="48" class="rounded" style="object-fit:cover;flex-shrink:0;"> <img src="{{ plant.thumbnail_url }}" width="44" height="44"
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% else %} {% else %}
<div style="width:48px;height:48px;background:#d8f3dc;border-radius:.375rem;flex-shrink:0;display:flex;align-items:center;justify-content:center;">🌿</div> <div class="w-11 h-11 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-xl">🌿</div>
{% endif %} {% endif %}
<div> <div>
<div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div> <div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ plant.name }}</div>
<small class="text-muted">{{ plant.location }}</small> <div class="font-nunito text-[10px] font-semibold text-[#B0A090]">{{ plant.location }}</div>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
</div> {% endif %}
{% 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,62 +1,109 @@
{# 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="d-flex align-items-center gap-2 mb-3"> <div class="px-4 pt-6">
<a href="{% url 'identify_upload' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0">Is this your plant?</h5>
</div>
<p class="text-muted small mb-3">Tap the correct species, then confirm.</p> <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"> <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="d-flex flex-column gap-2 mb-4" id="match-list"> <div class="flex flex-col gap-3 mb-5" id="match-list">
{% for m in matches %} {% for m in matches %}
<div class="match-card d-flex align-items-center gap-3 p-3 border rounded" <div class="match-card flex items-center gap-3 bg-white rounded-2xl p-3 shadow-sm cursor-pointer
style="cursor:pointer; {% if forloop.first %}border-color:#52b788 !important; background:#f0faf4;{% endif %}" {% if forloop.first %}ring-2 ring-[#F07040]{% 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 }}"
style="width:64px;height:64px;object-fit:cover;border-radius:8px;flex-shrink:0;"> class="w-16 h-16 rounded-xl flex-shrink-0 object-cover">
{% else %} {% else %}
<div style="width:64px;height:64px;background:#e9ecef;border-radius:8px;flex-shrink:0;"></div> <div class="w-16 h-16 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-2xl">🌿</div>
{% endif %} {% endif %}
<div class="flex-grow-1"> <div class="flex-1 min-w-0">
<div class="fw-bold">{{ m.scientific_name }}</div> <div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ m.scientific_name }}</div>
{% if m.common_names %} {% if m.common_names %}
<div class="text-muted small">{{ m.common_names|join:", " }}</div> <div class="font-nunito text-[10px] font-semibold text-[#B0A090] mt-0.5 truncate">
{% endif %} {{ m.common_names|join:", " }}
<div class="text-muted small fst-italic">{{ m.family }}</div>
</div> </div>
<div> {% endif %}
<span class="badge {% if m.score >= 0.70 %}bg-success{% elif m.score >= 0.30 %}bg-warning text-dark{% else %}bg-secondary{% 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 %}% {% widthratio m.score 1 100 %}%
</span> </span>
</div> </div>
</div>
{% endfor %} {% endfor %}
</div> </div>
<button type="submit" class="btn btn-success w-100" id="confirm-btn"> <!-- Plant name override -->
Use <span id="selected-name">{{ matches.0.scientific_name }}</span> <div class="mb-4">
<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(el) { function selectMatch(card) {
document.querySelectorAll('.match-card').forEach(c => { document.querySelectorAll('.match-card').forEach(c => c.classList.remove('ring-2', 'ring-[#F07040]'));
c.style.borderColor = ''; card.classList.add('ring-2', 'ring-[#F07040]');
c.style.background = ''; document.getElementById('match_idx_input').value = card.dataset.idx;
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]');
}); });
el.style.borderColor = '#52b788'; chip.classList.remove('bg-[#F5F0EA]', 'text-[#B0A090]');
el.style.background = '#f0faf4'; chip.classList.add('bg-[#F07040]', 'text-white');
document.getElementById('match_idx_input').value = el.dataset.idx; document.getElementById('location-id-input').value = chip.dataset.id;
document.getElementById('selected-name').textContent = el.dataset.name;
} }
</script> </script>
{% endblock %} {% endblock %}

View file

@ -17,21 +17,16 @@
<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 %}
<div class="dropdown"> <form method="get" action="{% url 'identify_fields' %}">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"> <select name="vpc_idx" onchange="this.form.submit()"
change 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]">
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for r in vpc_results %} {% for r in vpc_results %}
<li> <option value="{{ forloop.counter0 }}" {% if forloop.counter0 == vpc_idx %}selected{% endif %}>
<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 }}
</a> </option>
</li>
{% endfor %} {% endfor %}
</ul> </select>
</div> </form>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}

View file

@ -1,56 +1,96 @@
{# plants/templates/plants/identify_upload.html #}
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% block title %}Identify plant — BloomBase{% endblock %} {% block title %}Identify plant — BloomBase{% endblock %}
{% block content %} {% block content %}
<div class="d-flex align-items-center gap-2 mb-3"> <div class="px-4 pt-6">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0">Identify plant</h5>
</div>
<p class="text-muted small mb-3"> <!-- 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. Add one or more photos, tag what each shows, then tap Identify.
</p> </p>
{% if error %} {% if error %}
<div class="alert alert-warning py-2 small mb-3">{{ error }}</div> <div class="bg-orange-50 border border-orange-200 rounded-2xl px-4 py-3 font-nunito text-sm text-orange-700 mb-4">
{% endif %} {{ error }}
</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="d-none" id="file-picker"> <input type="file" name="images" accept="image/jpeg,image/png" class="hidden" id="file-picker">
<div id="photo-rows" class="mb-3"></div> <!-- Viewfinder / drop zone -->
<div class="relative bg-[#2C3820] rounded-2xl h-[140px] overflow-hidden mb-4" id="vf-box">
<div class="absolute inset-0"
style="background: radial-gradient(ellipse 60% 55% at 60% 45%, #C84060 0%, #E05878 30%, #F880A0 55%, transparent 75%),
linear-gradient(160deg, #1E3018 0%, #2E5028 30%, #3A6832 50%, #2C5020 70%);"></div>
<div class="vf-corner vf-tl"></div>
<div class="vf-corner vf-tr"></div>
<div class="vf-corner vf-bl"></div>
<div class="vf-corner vf-br"></div>
<!-- Controls row -->
<div class="absolute bottom-4 left-0 right-0 flex justify-center items-center gap-6">
<label for="file-picker"
class="w-9 h-9 bg-white/20 rounded-xl flex items-center justify-center border border-white/40 cursor-pointer">
<svg width="15" height="15" fill="none" stroke="white" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
</svg>
</label>
<label for="file-picker"
class="bb-fab cursor-pointer">
</label>
</div>
</div>
<div id="add-btn-wrap" class="mb-3"> <!-- Photo rows (populated by JS) -->
<label class="btn btn-outline-secondary w-100 py-3" for="file-picker" id="add-photo-label"> <div id="photo-rows" class="mb-4"></div>
📷 Add photo <span id="photo-count" class="text-muted small"></span>
<!-- Add more label (shown when < 5 photos) -->
<div id="add-btn-wrap" class="mb-4">
<label for="file-picker"
class="flex items-center justify-center gap-2 bg-white border border-[#F0E4D4] rounded-2xl py-3
font-nunito font-bold text-sm text-[#B0A090] cursor-pointer">
📷 Add photo <span id="photo-count" class="text-[#C0B0A0]"></span>
</label> </label>
</div> </div>
<button type="submit" class="btn btn-success w-100" id="identify-btn" disabled> <button type="submit" id="identify-btn" disabled
class="btn-bloom opacity-50 cursor-not-allowed">
Identify → Identify →
</button> </button>
</form> </form>
<div class="text-center mt-3"> <div class="text-center mt-4">
<a href="{% url 'plant_add' %}" class="text-muted small">Or enter name manually</a> <a href="{% url 'plant_add' %}" class="font-nunito text-sm font-semibold text-[#B0A090] no-underline">
</div> 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 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:'flower',label:'🌸 flower'},{val:'leaf',label:'🍃 leaf'},
{val: 'leaf', label: '🍃 leaf'}, {val:'habit',label:'🌿 habit'},{val:'fruit',label:'🍎 fruit'},
{val: 'habit', label: '🌿 habit'}, {val:'bark',label:'🪵 bark'},{val:'auto',label:'✨ auto'},
{val: 'fruit', label: '🍎 fruit'},
{val: 'bark', label: '🪵 bark'},
{val: 'auto', label: '✨ auto'},
]; ];
const selected = []; // {file, organ, objectUrl} const selected = [];
const picker = document.getElementById('file-picker'); const picker = document.getElementById('file-picker');
picker.addEventListener('change', function () { picker.addEventListener('change', function () {
@ -60,10 +100,7 @@ picker.addEventListener('change', function () {
render(); render();
}); });
function setOrgan(idx, organ) { function setOrgan(idx, organ) { selected[idx].organ = organ; render(); }
selected[idx].organ = organ;
render();
}
function removePhoto(idx) { function removePhoto(idx) {
URL.revokeObjectURL(selected[idx].objectUrl); URL.revokeObjectURL(selected[idx].objectUrl);
@ -76,38 +113,43 @@ function render() {
rows.innerHTML = ''; rows.innerHTML = '';
selected.forEach((item, i) => { selected.forEach((item, i) => {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'd-flex gap-2 align-items-start mb-3'; row.className = 'flex gap-3 items-start mb-4';
row.innerHTML = ` row.innerHTML = `
<div style="position:relative;flex-shrink:0;"> <div style="position:relative;flex-shrink:0;">
<img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:8px;" alt=""> <img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:14px;">
<button type="button" onclick="removePhoto(${i})" <button type="button" onclick="removePhoto(${i})"
style="position:absolute;top:2px;right:2px;background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:50%;width:20px;height:20px;font-size:10px;cursor:pointer;padding:0;line-height:1;">✕</button> style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.5);color:#fff;
border:none;border-radius:50%;width:20px;height:20px;font-size:10px;
cursor:pointer;padding:0;line-height:1;">✕</button>
</div> </div>
<div style="flex:1;"> <div style="flex:1;">
<div class="text-muted small mb-1">What's shown?</div> <div style="font-family:Nunito,sans-serif;font-size:11px;font-weight:700;color:#B0A090;margin-bottom:6px;">What's shown?</div>
<div class="d-flex flex-wrap gap-1"> <div style="display:flex;flex-wrap:wrap;gap:5px;">
${ORGANS.map(o => ` ${ORGANS.map(o=>`
<span onclick="setOrgan(${i}, '${o.val}')" <span onclick="setOrgan(${i},'${o.val}')"
style="cursor:pointer; padding:2px 8px; border-radius:10px; font-size:11px; ${item.organ === o.val ? 'background:#52b788;color:#fff;' : 'background:#e9ecef;color:#6c757d;'}"> style="cursor:pointer;padding:3px 10px;border-radius:20px;font-size:11px;font-family:Nunito,sans-serif;font-weight:700;
${item.organ===o.val?'background:#F07040;color:#fff;':'background:#F5F0EA;color:#B0A090;'}">
${o.label} ${o.label}
</span> </span>`).join('')}
`).join('')}
</div> </div>
<input type="hidden" name="organs" value="${item.organ}" id="organ-${i}"> <input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
</div>`; </div>`;
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' : '';
document.getElementById('identify-btn').disabled = selected.length === 0; const btn = document.getElementById('identify-btn');
btn.disabled = selected.length === 0;
btn.style.opacity = selected.length === 0 ? '0.5' : '1';
btn.style.cursor = selected.length === 0 ? 'not-allowed' : 'pointer';
} }
document.getElementById('identify-form').addEventListener('submit', function (e) { document.getElementById('identify-form').addEventListener('submit', function(e) {
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

@ -0,0 +1,67 @@
{# 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,21 +1,59 @@
{# 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="d-flex align-items-center gap-2 mb-3"> <div class="px-4 pt-6 pb-4">
<a href="{% url 'location_list' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0 flex-grow-1">Edit area</h5>
</div>
<form method="post"> <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 %} {% 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>
<!-- 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 %} {% endblock %}

View file

@ -1,36 +1,72 @@
{# plants/templates/plants/locations.html #}
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% block title %}Areas — BloomBase{% endblock %} {% block title %}My Garden — BloomBase{% endblock %}
{% block content %} {% block content %}
{% load static %}
<div class="d-flex align-items-center gap-2 mb-3"> <div class="px-4 pt-8 pb-4">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0 flex-grow-1">Areas</h5>
</div>
<div class="list-group mb-3"> <!-- Header row -->
{% for loc in locations %} <div class="flex justify-between items-center mb-4">
<div class="list-group-item d-flex justify-content-between align-items-center py-2"> <h1 class="font-nunito font-black text-[22px] text-[#1A1208]">My Garden</h1>
<span>{{ loc.name }} <small class="text-muted">{{ loc.plant_count }} plant{{ loc.plant_count|pluralize }}</small></span> <a href="#" class="font-nunito font-bold text-sm text-[#F07040]">Edit</a>
<div class="d-flex gap-1"> </div>
<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 %}" <!-- Search bar -->
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 %}"> <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 %} {% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-2"></button> <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> </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>
</div>
{% endblock %} {% endblock %}

View file

@ -1,30 +1,35 @@
<div class="mb-3"> <div class="mb-4">
<h6 class="mb-2">Photos</h6> <h6 class="font-nunito font-bold text-sm text-[#1A1208] mb-2">Photos</h6>
{% with photos=plant.photos.all %} {% with photos=plant.photos.all %}
{% if photos %} {% if photos %}
<div class="d-flex flex-wrap gap-2 mb-2"> <div class="flex flex-wrap gap-2 mb-3">
{% for photo in photos %} {% for photo in photos %}
<div class="position-relative"> <div class="relative">
<img src="{{ photo.image.url }}" width="80" height="80" <img src="{{ photo.image.url }}" width="80" height="80"
class="rounded {% if photo.is_thumbnail %}border border-success border-2{% endif %}" class="rounded-lg object-cover {% if photo.is_thumbnail %}ring-2 ring-[#52B788]{% endif %}"
style="object-fit:cover;" alt=""> style="object-fit:cover;" alt="">
<div class="d-flex gap-1 mt-1"> <div class="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" class="btn btn-outline-success btn-sm py-0 px-1" style="font-size:.7rem;" title="Set as thumbnail"></button> <button type="submit"
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="btn btn-success btn-sm py-0 px-1 disabled" style="font-size:.7rem;" title="Thumbnail"></span> <span class="inline-flex items-center justify-center w-6 h-6 rounded bg-[#52B788] text-white text-xs leading-none cursor-default"
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" class="btn btn-outline-danger btn-sm py-0 px-1" style="font-size:.7rem;" title="Delete"></button> <button type="submit"
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>
@ -37,13 +42,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="d-flex gap-2 align-items-center flex-wrap"> <div class="flex gap-2 items-center flex-wrap">
<label class="btn btn-sm btn-outline-secondary mb-0" for="photo-upload-input">📷 Choose photo</label> <label class="btn-bloom-outline text-xs px-3 py-1 cursor-pointer" for="photo-upload-input">📷 Choose photo</label>
<input type="file" id="photo-upload-input" name="image" accept="image/*" class="d-none" <input type="file" id="photo-upload-input" name="image" accept="image/*" class="hidden"
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-muted small"></span> <span id="photo-filename" class="text-xs text-[#B0A090]"></span>
<button type="submit" class="btn btn-sm btn-outline-secondary">Upload</button> <button type="submit" class="btn-bloom-outline text-xs px-3 py-1">Upload</button>
<span id="photo-upload-spinner" class="htmx-indicator text-muted small">Uploading…</span> <span id="photo-upload-spinner" class="htmx-indicator text-xs text-[#B0A090]">Uploading…</span>
</div> </div>
</form> </form>
</div> </div>

View file

@ -1,23 +1,26 @@
{# plants/templates/plants/partials/plant_list_results.html #}
{% for plant in plants %} {% for plant in plants %}
<a href="{% url 'plant_detail' plant.pk %}" class="text-decoration-none"> <a href="{% url 'plant_detail' plant.pk %}"
<div class="card mb-2"> 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-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="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;"> <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 %} {% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;"> <img src="{{ plant.thumbnail_url }}" width="52" height="52"
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% else %} {% else %}
<div style="width:48px; height:48px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div> <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 %} {% 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="text-muted text-center mt-4">No plants found.</p> <p class="font-nunito text-sm text-[#B0A090] text-center py-8">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-3 rounded-end"> <div id="pruning-strip" class="pruning-strip {% if pruning_status == 'overdue' %}overdue{% endif %} mb-4 p-3 rounded-2xl bg-[#E8F4EC]">
<div class="d-flex justify-content-between align-items-start gap-2"> <div class="flex justify-between items-start gap-2">
<div> <div>
<strong>✂️ Pruning</strong> <p class="font-nunito font-bold text-[11px] text-[#1b4332] mb-1">✂️ Pruning months</p>
{% 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="small text-muted mt-1 text-nowrap">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div> <div class="text-xs text-[#B0A090] mt-1 whitespace-nowrap">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>
<div class="flex-shrink-0"> <div class="shrink-0">
{% if pruning_status == 'overdue' %} {% if pruning_status == 'overdue' %}
<span class="badge bg-danger">Overdue</span> <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>
{% elif pruning_status == 'due_this_month' %} {% elif pruning_status == 'due_this_month' %}
<span class="badge bg-warning text-dark">Due this month</span> <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>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -28,9 +28,9 @@
class="mt-2" class="mt-2"
> >
{% csrf_token %} {% csrf_token %}
<div class="d-flex gap-2 align-items-end flex-wrap"> <div class="flex gap-2 items-end flex-wrap">
{{ log_form.pruned_on }} {{ log_form.pruned_on }}
<button type="submit" class="btn btn-sm btn-success">Log pruning</button> <button type="submit" class="btn-bloom-outline text-xs px-3 py-1">Log pruning</button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -1,109 +1,131 @@
{# 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 %}
<div class="d-flex align-items-center gap-2 mb-2"> <!-- Hero image -->
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a> <div class="relative h-[220px] overflow-hidden" id="plant-hero-container">
<h5 class="mb-0 flex-grow-1">{{ plant.name }}</h5> {% if plant.species and plant.species.api_image_url %}
<a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a> <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>
<div id="plant-hero-container"> <div class="px-4 pt-3 pb-4">
{% 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>
<div class="d-flex justify-content-between align-items-start mb-1"> <!-- Plant name + latin -->
<div> <h1 class="font-nunito font-black text-[22px] text-[#1A1208] mb-0.5">{{ plant.name }}</h1>
<h6 class="mb-0">{{ plant.name }}</h6> {% if plant.species %}
<div class="d-flex align-items-center gap-1"> <div class="flex items-center gap-1.5 mb-3">
{% if plant.species %}<small class="text-muted fst-italic">{{ plant.species.scientific_name }}</small>{% endif %} <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" title="Search on Google"> <a href="https://www.google.com/search?q={{ plant.species.scientific_name|default:plant.name|urlencode }}"
<img src="https://www.google.com/favicon.ico" width="14" height="14" style="opacity:0.7;vertical-align:middle;"> target="_blank" rel="noopener">
<img src="https://www.google.com/favicon.ico" width="13" height="13" style="opacity:0.65;vertical-align:middle;">
</a> </a>
</div> </div>
</div> {% endif %}
<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 %}<p class="text-muted small mb-2">📍 {{ plant.location.name }}</p>{% endif %} {% if plant.location %}
<p class="font-nunito text-[11px] font-semibold text-[#B0A090] mb-3">
📍 {{ plant.location.name }}
</p>
{% endif %}
{% if plant.watering or plant.sunlight or plant.species %} <!-- Care row -->
<div class="mb-3"> {% 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 %} {% if plant.watering %}
<span class="care-chip" style="background:#e3f2fd; color:#1565c0;">{{ plant.get_watering_display }}</span> <div class="flex flex-col items-center gap-1">
{% elif plant.species.watering %} <span class="text-xl">💧</span>
<span class="care-chip" style="background:#e3f2fd; color:#1565c0;">💧 {{ plant.species.watering }}</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 %} {% endif %}
{% if plant.sunlight %} {% if plant.sunlight %}
<span class="care-chip" style="background:#fff9c4; color:#7d4a00;">{{ plant.get_sunlight_display }}</span> <div class="flex flex-col items-center gap-1">
{% elif plant.species.sunlight %} <span class="text-xl">☀️</span>
<span class="care-chip" style="background:#fff9c4; color:#7d4a00;">☀️ {{ plant.species.sunlight }}</span> <span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.get_sunlight_display }}</span>
{% endif %} </div>
{% if plant.species.max_height_cm %} {% elif plant.species and plant.species.sunlight %}
<span class="care-chip" style="background:#f3e5f5; color:#4a148c;">📏 up to {{ plant.species.max_height_cm }} cm</span> <div class="flex flex-col items-center gap-1">
{% endif %} <span class="text-xl">☀️</span>
{% if plant.species.frost_hardiness_c is not None %} <span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.sunlight }}</span>
<span class="care-chip" style="background:#e3f2fd; color:#0d47a1;">❄️ {{ plant.species.frost_hardiness_c }}°C</span> </div>
{% endif %} {% endif %}
{% if plant.species.planting_density_m2 %} {% if plant.species and plant.species.frost_hardiness_c is not None %}
<span class="care-chip" style="background:#f9fbe7; color:#558b2f;">🌿 {{ plant.species.planting_density_m2 }}/m²</span> <div class="flex flex-col items-center gap-1">
{% endif %} <span class="text-xl">🌡️</span>
{% if plant.species.growth_rate %} <span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.frost_hardiness_c }}°C min</span>
<span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span> </div>
{% endif %} {% endif %}
</div> {% if plant.species and plant.species.max_height_cm %}
{% endif %} <div class="flex flex-col items-center gap-1">
{% if plant.species and plant.species.vpc_slug %} <span class="text-xl">📏</span>
<div class="mb-3"> <span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.max_height_cm }} cm</span>
<form method="post" action="{% url 'vpc_species_reset' plant.species.pk %}"> </div>
{% csrf_token %} {% endif %}
<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>
</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 %} {% endif %}
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-sm btn-outline-danger">Delete plant</a>
</div>
<!-- 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 %}
<!-- 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>
{% endblock %} {% endblock %}

View file

@ -1,34 +1,58 @@
{# 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">
<a href="{% url 'plant_add' %}" class="btn btn-success">+ Add a plant</a> <div class="px-4 pt-6">
</div>
<div class="mb-3"> <!-- 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 <input
type="search" type="search"
name="q" name="q"
value="{{ q }}" value="{{ q }}"
class="form-control" placeholder="Search plants…"
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-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>
<div class="d-flex align-items-center gap-2 mb-3"> <!-- Location filter chips -->
<div class="btn-group flex-wrap flex-grow-1" role="group"> <div class="flex gap-2 overflow-x-auto pb-2 mb-4 scrollbar-hide">
<a href="{% url 'plant_list' %}" class="btn btn-sm {% if not location_filter %}btn-success{% else %}btn-outline-success{% endif %}">All</a> <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 %} {% for loc in locations %}
<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> <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 %} {% endfor %}
</div> </div>
<a href="{% url 'location_list' %}" class="btn btn-sm btn-outline-secondary flex-shrink-0" title="Manage areas">⚙️</a>
</div>
<div id="plant-list"> <!-- 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

@ -0,0 +1,51 @@
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,14 +62,6 @@ 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,6 +23,7 @@ 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,6 +1,7 @@
# 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 from plants.models import Plant, Location
def dashboard(request): def dashboard(request):
@ -8,6 +9,7 @@ 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),
@ -15,4 +17,6 @@ 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 from plants.models import Plant, PlantPhoto, Species, Location
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,14 +27,14 @@ def _clear_session(request):
def identify_upload(request): def identify_upload(request):
if request.method != 'POST': if request.method != 'POST':
return render(request, 'plants/identify_upload.html') return render(request, 'plants/identify_upload.html', {'active_tab': 'home'})
files = request.FILES.getlist('images') files = request.FILES.getlist('images')
organs = request.POST.getlist('organs') organs = request.POST.getlist('organs')
if not files: if not files:
return render(request, 'plants/identify_upload.html', return render(request, 'plants/identify_upload.html',
{'error': 'Please select at least one photo.'}) {'error': 'Please select at least one photo.', 'active_tab': 'home'})
scan_id = uuid.uuid4().hex scan_id = uuid.uuid4().hex
saved_paths = [] saved_paths = []
@ -66,7 +66,7 @@ def identify_upload(request):
default_storage.delete(p['path']) default_storage.delete(p['path'])
except Exception: except Exception:
pass pass
return render(request, 'plants/identify_upload.html', {'error': error_msg}) return render(request, 'plants/identify_upload.html', {'error': error_msg, 'active_tab': 'home'})
request.session['identify_matches'] = matches request.session['identify_matches'] = matches
request.session['identify_image_paths'] = saved_paths request.session['identify_image_paths'] = saved_paths
@ -100,7 +100,11 @@ def identify_confirm(request):
return redirect('identify_fields') return redirect('identify_fields')
return render(request, 'plants/identify_confirm.html', {'matches': matches}) locations = list(Location.objects.all())
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,6 +2,7 @@ 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):
@ -12,18 +13,36 @@ 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', {'locations': locations}) 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): 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':
name = request.POST.get('name', '').strip() form = LocationForm(request.POST, request.FILES, instance=location)
if name: if form.is_valid():
location.name = name form.save()
location.save()
return redirect('location_list') return redirect('location_list')
return render(request, 'plants/location_edit.html', {'location': location}) else:
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,6 +27,7 @@ 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',
}) })
@ -41,6 +42,7 @@ 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 = plantdb.settings DJANGO_SETTINGS_MODULE = bloombase.settings