Compare commits
No commits in common. "b9a84a41c51d4e5bfd1599221a8b256b5d4478fd" and "c1d0f0a3464c7a9644088d911ca45fcb637963b7" have entirely different histories.
b9a84a41c5
...
c1d0f0a346
27 changed files with 461 additions and 2902 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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 | 32–46px |
|
|
||||||
| Page headlines | Nunito | 900 | 21–28px |
|
|
||||||
| Section titles | Nunito | 800 | 16–19px |
|
|
||||||
| Body / labels | Nunito | 600–700 | 9–12px |
|
|
||||||
| Tagline / CTA sub | Pacifico | 400 | 14–16px |
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
@ -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']
|
|
||||||
|
|
|
||||||
|
|
@ -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/'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
@ -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://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<style>
|
||||||
<script>
|
body { padding-bottom: 1rem; }
|
||||||
tailwind.config = {
|
.plant-hero { width: 100%; height: 220px; object-fit: cover; }
|
||||||
theme: {
|
.plant-hero-placeholder { width: 100%; height: 220px; background: #d8f3dc; display: flex; align-items: center; justify-content: center; color: #52b788; font-size: 3rem; }
|
||||||
extend: {
|
.care-chip { display: inline-block; padding: .2rem .55rem; border-radius: 1rem; font-size: .8rem; margin: .15rem; }
|
||||||
colors: {
|
.pruning-strip { border-left: 4px solid #ffc107; background: #fff9c4; padding: .5rem .75rem; border-radius: 0 .375rem .375rem 0; }
|
||||||
bloom: {
|
.pruning-strip.overdue { border-color: #dc3545; background: #fff5f5; }
|
||||||
bg: '#FFF8F2', primary: '#F07040', green: '#52B788',
|
/* Month strip — display */
|
||||||
dark: '#1A1208', muted: '#B0A090', border: '#F0E4D4',
|
.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; }
|
||||||
fontFamily: {
|
/* Month toggle — form */
|
||||||
nunito: ['Nunito', 'sans-serif'],
|
.month-toggle-group { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
script: ['"Great Vibes"', 'cursive'],
|
.month-toggle { position: relative; }
|
||||||
pacifico: ['Pacifico', 'cursive'],
|
.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>
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
|
||||||
{% block extra_head %}{% endblock %}
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#FFF8F2]">
|
<body>
|
||||||
|
<nav class="navbar navbar-dark px-3" style="background-color: #2d6a4f;">
|
||||||
|
<a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌸 BloomBase</a>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<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>
|
||||||
|
<a href="{% url 'identify_upload' %}" class="btn btn-sm btn-light fw-bold">+</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Canvas background blobs -->
|
<div class="container-fluid px-3 pt-3">
|
||||||
<div class="bb-blob-tl"></div>
|
{% block content %}{% endblock %}
|
||||||
<div class="bb-blob-br"></div>
|
</div>
|
||||||
|
|
||||||
<!-- Canvas SVG flowers -->
|
|
||||||
<svg class="bb-flower bb-flower-tr" width="140" height="140" viewBox="0 0 140 140" xmlns="http://www.w3.org/2000/svg" opacity="0.85">
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="hp1" cx="50%" cy="80%" r="85%"><stop offset="0%" stop-color="#A02848"/><stop offset="35%" stop-color="#D04870"/><stop offset="70%" stop-color="#F07898"/><stop offset="100%" stop-color="#FFCCD8"/></radialGradient>
|
|
||||||
<radialGradient id="hp2" cx="50%" cy="80%" r="85%"><stop offset="0%" stop-color="#B83060"/><stop offset="35%" stop-color="#E06080"/><stop offset="70%" stop-color="#F898B0"/><stop offset="100%" stop-color="#FFDDEA"/></radialGradient>
|
|
||||||
</defs>
|
|
||||||
<g transform="translate(70,70)">
|
|
||||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(0)"/>
|
|
||||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp2)" transform="rotate(72)" opacity="0.92"/>
|
|
||||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(144)" opacity="0.88"/>
|
|
||||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp2)" transform="rotate(216)" opacity="0.93"/>
|
|
||||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(288)" opacity="0.87"/>
|
|
||||||
<rect x="-3.5" y="-20" width="7" height="20" rx="3.5" fill="#FFD060"/>
|
|
||||||
<circle r="6" fill="#FFD040"/><circle r="3" fill="#E84020"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<svg class="bb-flower bb-flower-ml" width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" opacity="0.7">
|
|
||||||
<defs><radialGradient id="pp" cx="50%" cy="80%" r="80%"><stop offset="0%" stop-color="#C03058"/><stop offset="50%" stop-color="#E86090"/><stop offset="100%" stop-color="#FFB8CC"/></radialGradient></defs>
|
|
||||||
<g transform="translate(36,36)">
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(0)"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(72)" opacity="0.9"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(144)"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(216)" opacity="0.9"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(288)"/>
|
|
||||||
<circle r="5" fill="#FFD040"/><circle r="2.5" fill="#E84020"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<svg class="bb-flower bb-flower-br" width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg" opacity="0.8">
|
|
||||||
<defs><radialGradient id="yp" cx="50%" cy="80%" r="80%"><stop offset="0%" stop-color="#D08000"/><stop offset="50%" stop-color="#F0C020"/><stop offset="100%" stop-color="#FFE880"/></radialGradient></defs>
|
|
||||||
<g transform="translate(48,48)">
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(0)"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(45)" opacity="0.9"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(90)"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(135)" opacity="0.9"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(180)"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(225)" opacity="0.9"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(270)"/>
|
|
||||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(315)" opacity="0.9"/>
|
|
||||||
<circle r="9" fill="#E06010"/><circle r="5" fill="#C04000"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<svg class="bb-flower bb-flower-bl" width="56" height="56" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg" opacity="0.65">
|
|
||||||
<g transform="translate(28,28)">
|
|
||||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(0)"/>
|
|
||||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(60)"/>
|
|
||||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(120)"/>
|
|
||||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(180)"/>
|
|
||||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(240)"/>
|
|
||||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(300)"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Page content — pb-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>
|
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</div>
|
||||||
|
<div>{% month_strip plant.bloom_months %}</div>
|
||||||
<!-- Camera viewfinder — tap → identify_upload -->
|
</a>
|
||||||
<a href="{% url 'identify_upload' %}" class="block relative bg-[#2C3820] rounded-2xl h-[120px] overflow-hidden mb-4 no-underline">
|
{% endfor %}
|
||||||
<!-- 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>
|
||||||
</a>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- My Garden pill -->
|
{% if latest_plants %}
|
||||||
<a href="{% url 'location_list' %}"
|
<div class="card mb-3">
|
||||||
class="flex items-center gap-3 bg-white/80 rounded-2xl px-4 py-3 border border-[#F0E4D4] mb-5 no-underline">
|
<div class="card-header fw-semibold">🌿 Recently added</div>
|
||||||
<span class="text-2xl">🪴</span>
|
<div class="list-group list-group-flush">
|
||||||
<div class="flex-1">
|
{% for plant in latest_plants %}
|
||||||
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">My Garden</div>
|
<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">
|
||||||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090]">
|
{% if plant.species and plant.species.api_image_url %}
|
||||||
{{ total_plants }} Plant{{ total_plants|pluralize }} · {{ location_count }} Area{{ location_count|pluralize }}
|
<img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover;flex-shrink:0;">
|
||||||
</div>
|
{% elif plant.thumbnail_url %}
|
||||||
|
<img src="{{ plant.thumbnail_url }}" width="48" height="48" class="rounded" style="object-fit:cover;flex-shrink:0;">
|
||||||
|
{% else %}
|
||||||
|
<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>
|
||||||
|
<div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div>
|
||||||
|
<small class="text-muted">{{ plant.location }}</small>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[#D0C0B0] font-bold text-lg">›</span>
|
</div>
|
||||||
</a>
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recently added -->
|
|
||||||
{% if latest_plants %}
|
|
||||||
<h2 class="font-nunito font-black text-[16px] text-[#1A1208] mb-3">🌿 Recently added</h2>
|
|
||||||
<div class="flex flex-col gap-2 mb-4">
|
|
||||||
{% for plant in latest_plants %}
|
|
||||||
<a href="{% url 'plant_detail' plant.pk %}"
|
|
||||||
class="flex items-center gap-3 bg-white rounded-2xl px-3 py-2.5 shadow-sm no-underline">
|
|
||||||
{% if plant.species and plant.species.api_image_url %}
|
|
||||||
<img src="{{ plant.species.api_image_url }}" width="44" height="44"
|
|
||||||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
|
||||||
{% elif plant.thumbnail_url %}
|
|
||||||
<img src="{{ plant.thumbnail_url }}" width="44" height="44"
|
|
||||||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
|
||||||
{% else %}
|
|
||||||
<div class="w-11 h-11 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-xl">🌿</div>
|
|
||||||
{% endif %}
|
|
||||||
<div>
|
|
||||||
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ plant.name }}</div>
|
|
||||||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090]">{{ plant.location }}</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
<a href="{% url 'plant_add' %}" class="btn btn-success">+ Add a plant</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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:", " }}
|
{% endif %}
|
||||||
</div>
|
<div class="text-muted small fst-italic">{{ m.family }}</div>
|
||||||
{% endif %}
|
</div>
|
||||||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] italic">{{ m.family }}</div>
|
<div>
|
||||||
|
<span class="badge {% if m.score >= 0.70 %}bg-success{% elif m.score >= 0.30 %}bg-warning text-dark{% else %}bg-secondary{% endif %}">
|
||||||
|
{% widthratio m.score 1 100 %}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-nunito font-bold text-[11px] px-2 py-1 rounded-full flex-shrink-0
|
|
||||||
{% if m.score >= 0.70 %}bg-green-100 text-green-700
|
|
||||||
{% elif m.score >= 0.30 %}bg-orange-100 text-orange-700
|
|
||||||
{% else %}bg-gray-100 text-gray-500{% endif %}">
|
|
||||||
{% widthratio m.score 1 100 %}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Plant name override -->
|
|
||||||
<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 %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button type="submit" class="btn-bloom">
|
<button type="submit" class="btn btn-success w-100" id="confirm-btn">
|
||||||
Add to my garden →
|
Use <span id="selected-name">{{ matches.0.scientific_name }}</span> →
|
||||||
</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) {
|
el.style.borderColor = '#52b788';
|
||||||
nameInput.value = card.dataset.name;
|
el.style.background = '#f0faf4';
|
||||||
nameInput.dataset.autoSet = '1';
|
document.getElementById('match_idx_input').value = el.dataset.idx;
|
||||||
}
|
document.getElementById('selected-name').textContent = el.dataset.name;
|
||||||
}
|
|
||||||
|
|
||||||
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]');
|
|
||||||
chip.classList.add('bg-[#F07040]', 'text-white');
|
|
||||||
document.getElementById('location-id-input').value = chip.dataset.id;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
{{ r.common_name }}
|
<a class="dropdown-item {% if forloop.counter0 == vpc_idx %}active{% endif %}"
|
||||||
</option>
|
href="{% url 'identify_fields' %}?vpc_idx={{ forloop.counter0 }}">
|
||||||
|
{{ r.common_name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</ul>
|
||||||
</form>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
||||||
|
|
@ -1,160 +1,118 @@
|
||||||
{# 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"
|
<div id="add-btn-wrap" class="mb-3">
|
||||||
style="background: radial-gradient(ellipse 60% 55% at 60% 45%, #C84060 0%, #E05878 30%, #F880A0 55%, transparent 75%),
|
<label class="btn btn-outline-secondary w-100 py-3" for="file-picker" id="add-photo-label">
|
||||||
linear-gradient(160deg, #1E3018 0%, #2E5028 30%, #3A6832 50%, #2C5020 70%);"></div>
|
📷 Add photo <span id="photo-count" class="text-muted small"></span>
|
||||||
<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>
|
||||||
<label for="file-picker"
|
|
||||||
class="bb-fab cursor-pointer">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Photo rows (populated by JS) -->
|
<button type="submit" class="btn btn-success w-100" id="identify-btn" disabled>
|
||||||
<div id="photo-rows" class="mb-4"></div>
|
Identify →
|
||||||
|
|
||||||
<!-- Add more label (shown when < 5 photos) -->
|
|
||||||
<div id="add-btn-wrap" class="mb-4">
|
|
||||||
<label for="file-picker"
|
|
||||||
class="flex items-center justify-center gap-2 bg-white border border-[#F0E4D4] rounded-2xl py-3
|
|
||||||
font-nunito font-bold text-sm text-[#B0A090] cursor-pointer">
|
|
||||||
📷 Add photo <span id="photo-count" class="text-[#C0B0A0]"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" id="identify-btn" disabled
|
|
||||||
class="btn-bloom opacity-50 cursor-not-allowed">
|
|
||||||
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 () {
|
||||||
if (!this.files[0] || selected.length >= 5) return;
|
if (!this.files[0] || selected.length >= 5) return;
|
||||||
selected.push({file: this.files[0], organ: 'auto', objectUrl: URL.createObjectURL(this.files[0])});
|
selected.push({file: this.files[0], organ: 'auto', objectUrl: URL.createObjectURL(this.files[0])});
|
||||||
this.value = '';
|
this.value = '';
|
||||||
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);
|
||||||
selected.splice(idx, 1);
|
selected.splice(idx, 1);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const rows = document.getElementById('photo-rows');
|
const rows = document.getElementById('photo-rows');
|
||||||
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;
|
</div>
|
||||||
cursor:pointer;padding:0;line-height:1;">✕</button>
|
<div style="flex:1;">
|
||||||
</div>
|
<div class="text-muted small mb-1">What's shown?</div>
|
||||||
<div style="flex:1;">
|
<div class="d-flex flex-wrap gap-1">
|
||||||
<div style="font-family:Nunito,sans-serif;font-size:11px;font-weight:700;color:#B0A090;margin-bottom:6px;">What's shown?</div>
|
${ORGANS.map(o => `
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:5px;">
|
<span onclick="setOrgan(${i}, '${o.val}')"
|
||||||
${ORGANS.map(o=>`
|
style="cursor:pointer; padding:2px 8px; border-radius:10px; font-size:11px; ${item.organ === o.val ? 'background:#52b788;color:#fff;' : 'background:#e9ecef;color:#6c757d;'}">
|
||||||
<span onclick="setOrgan(${i},'${o.val}')"
|
${o.label}
|
||||||
style="cursor:pointer;padding:3px 10px;border-radius:20px;font-size:11px;font-family:Nunito,sans-serif;font-weight:700;
|
</span>
|
||||||
${item.organ===o.val?'background:#F07040;color:#fff;':'background:#F5F0EA;color:#B0A090;'}">
|
`).join('')}
|
||||||
${o.label}
|
</div>
|
||||||
</span>`).join('')}
|
<input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
|
||||||
</div>
|
</div>`;
|
||||||
<input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
|
rows.appendChild(row);
|
||||||
</div>`;
|
});
|
||||||
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));
|
||||||
picker.files = dt.files;
|
picker.files = dt.files;
|
||||||
document.getElementById('identify-spinner').style.display = 'flex';
|
document.getElementById('identify-spinner').style.display = 'flex';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
{% if plant.species and plant.species.api_image_url %}
|
<div class="card-body d-flex align-items-center gap-3 py-2">
|
||||||
<img src="{{ plant.species.api_image_url }}" width="52" height="52"
|
{% if plant.species and plant.species.api_image_url %}
|
||||||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
<img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
|
||||||
{% 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 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 %}
|
||||||
{% endif %}
|
<div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div>
|
||||||
<div class="font-nunito font-extrabold text-[14px] text-[#1A1208] truncate">{{ plant.name }}</div>
|
<small class="text-muted">
|
||||||
{% if plant.location %}
|
{{ 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">
|
</small>
|
||||||
{{ plant.location.name }}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
{% endif %}
|
</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 %}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
{% 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</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 %}
|
{% 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>
|
{% endif %}
|
||||||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.max_height_cm }} cm</span>
|
{% if plant.species.planting_density_m2 %}
|
||||||
</div>
|
<span class="care-chip" style="background:#f9fbe7; color:#558b2f;">🌿 {{ plant.species.planting_density_m2 }}/m²</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if plant.species.growth_rate %}
|
||||||
|
<span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Bloom strip -->
|
|
||||||
{% if plant.bloom_months %}
|
|
||||||
<div class="mb-4 p-3 rounded-2xl bg-[#D8F3DC]">
|
|
||||||
<p class="font-nunito font-bold text-[11px] text-[#1b4332] mb-1">🌸 Bloom months</p>
|
|
||||||
{% month_strip plant.bloom_months %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Notes -->
|
|
||||||
{% if plant.notes %}
|
|
||||||
<div class="bg-white rounded-2xl px-4 py-3 border border-[#F0E4D4] mb-4">
|
|
||||||
<p class="font-nunito text-[13px] text-[#6B7280]">{{ plant.notes|linebreaksbr }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Pruning -->
|
|
||||||
{% if plant.pruning_months %}
|
|
||||||
{% include "plants/partials/pruning_strip.html" %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- 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 %}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
{% for loc in locations %}
|
||||||
{% if not location_filter %}bg-[#F07040] text-white{% else %}bg-white text-[#B0A090] border border-[#F0E4D4]{% endif %}
|
<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>
|
||||||
no-underline">
|
{% endfor %}
|
||||||
All
|
</div>
|
||||||
</a>
|
<a href="{% url 'location_list' %}" class="btn btn-sm btn-outline-secondary flex-shrink-0" title="Manage areas">⚙️</a>
|
||||||
{% for loc in locations %}
|
</div>
|
||||||
<a href="{% url 'plant_list' %}?location={{ loc.pk }}"
|
|
||||||
class="flex-shrink-0 px-4 py-1.5 rounded-full font-nunito font-bold text-[12px]
|
|
||||||
{% if location_filter == loc.pk|stringformat:'s' %}bg-[#F07040] text-white{% else %}bg-white text-[#B0A090] border border-[#F0E4D4]{% endif %}
|
|
||||||
no-underline">
|
|
||||||
{{ loc.name }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Plant list -->
|
<div id="plant-list">
|
||||||
<div id="plant-list">
|
|
||||||
{% include "plants/partials/plant_list_results.html" %}
|
{% include "plants/partials/plant_list_results.html" %}
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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/')
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
return redirect('location_list')
|
location.save()
|
||||||
else:
|
return redirect('location_list')
|
||||||
form = LocationForm(instance=location)
|
return render(request, 'plants/location_edit.html', {'location': location})
|
||||||
return render(request, 'plants/location_edit.html', {
|
|
||||||
'location': location,
|
|
||||||
'form': form,
|
|
||||||
'active_tab': 'garden',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
[pytest]
|
[pytest]
|
||||||
DJANGO_SETTINGS_MODULE = bloombase.settings
|
DJANGO_SETTINGS_MODULE = plantdb.settings
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue