Compare commits
16 commits
c1d0f0a346
...
b9a84a41c5
| Author | SHA1 | Date | |
|---|---|---|---|
| b9a84a41c5 | |||
| 4e7655bf46 | |||
| 2c45a6f2ee | |||
| 4cac6cd0ce | |||
| 78f5f3b54a | |||
| cbd58fd2dc | |||
| d7cb9ea25d | |||
| b320b1a471 | |||
| 3149206c3e | |||
| c9905851c2 | |||
| e699ddb62d | |||
| 2d7ca494bd | |||
| ea627ad4ef | |||
| 69837d88b2 | |||
| 29fd14c06e | |||
| ff4ff7645c |
27 changed files with 2901 additions and 460 deletions
1613
docs/superpowers/plans/2026-05-31-bloombase-redesign.md
Normal file
1613
docs/superpowers/plans/2026-05-31-bloombase-redesign.md
Normal file
File diff suppressed because it is too large
Load diff
200
docs/superpowers/specs/2026-05-31-bloombase-redesign-design.md
Normal file
200
docs/superpowers/specs/2026-05-31-bloombase-redesign-design.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# BloomBase UI Redesign — Design Spec
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Project:** plantdb (BloomBase)
|
||||
**Primary user:** Naomi (plant owner, photographer, Dutch Farmhouse content creator)
|
||||
**Inspired by:** Naomi's own design mockup — vibrant organic poster aesthetic
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
Full visual redesign of the BloomBase Django app, replacing Bootstrap 5 with Tailwind CSS. New bottom navigation. Location model extended with cover photo. All existing screens restyled. One new screen (Area Detail).
|
||||
|
||||
**In scope:**
|
||||
- Replace Bootstrap 5 with Tailwind CSS (CDN play/JIT acceptable; production: CLI build)
|
||||
- Rewrite `base.html` with bottom navigation
|
||||
- Restyle all existing templates: dashboard, plant list, plant detail, locations, identify flow
|
||||
- New Area Detail page (cover photo header + plant grid)
|
||||
- `Location.cover_photo` ImageField + migration + upload UI
|
||||
|
||||
**Out of scope:**
|
||||
- Care tab new features (stub tab only)
|
||||
- Profile tab (stub tab only)
|
||||
- Authentication / multi-user
|
||||
|
||||
---
|
||||
|
||||
## 2. Visual System
|
||||
|
||||
### Color Palette
|
||||
| Role | Value |
|
||||
|---|---|
|
||||
| Page background | `#FFF8F2` (warm cream) |
|
||||
| Primary / CTA | `#F07040` coral-orange |
|
||||
| CTA gradient | `linear-gradient(135deg, #F58040, #E84020)` |
|
||||
| Accent / nature | `#52B788` green |
|
||||
| Dark text | `#1A1208` |
|
||||
| Muted text | `#B0A090` |
|
||||
| Card background | `#FFFFFF` |
|
||||
| Card border / subtle | `#F0E4D4` |
|
||||
| Tint background | `#FFF8F2` |
|
||||
|
||||
### Typography
|
||||
| Usage | Font | Weight | Size |
|
||||
|---|---|---|---|
|
||||
| Brand name "Bloombase" | Great Vibes (approximates Timeless) | 400 | 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,3 +71,9 @@ class PruningLogForm(forms.ModelForm):
|
|||
'pruned_on': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}),
|
||||
}
|
||||
|
||||
|
||||
class LocationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = ['name', 'cover_photo']
|
||||
|
|
|
|||
18
plants/migrations/0011_location_cover_photo.py
Normal file
18
plants/migrations/0011_location_cover_photo.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.1 on 2026-06-01 14:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plants', '0010_add_frost_density_to_species'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='location',
|
||||
name='cover_photo',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='locations/'),
|
||||
),
|
||||
]
|
||||
|
|
@ -3,6 +3,7 @@ from django.db import models
|
|||
|
||||
class Location(models.Model):
|
||||
name = models.CharField(max_length=200, unique=True)
|
||||
cover_photo = models.ImageField(upload_to='locations/', blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
|
|
|||
110
plants/static/plants/css/bloombase.css
Normal file
110
plants/static/plants/css/bloombase.css
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/* plants/static/plants/css/bloombase.css */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Great+Vibes&family=Pacifico&display=swap');
|
||||
|
||||
/* ── Body canvas ── */
|
||||
body {
|
||||
background-color: #FFF8F2;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Background blob shapes ── */
|
||||
.bb-blob-tl {
|
||||
position: fixed; top: -100px; left: -80px;
|
||||
width: 380px; height: 380px;
|
||||
background: radial-gradient(ellipse at 45% 45%, #F9C090 0%, #F07040 55%, #E05020 100%);
|
||||
border-radius: 62% 38% 54% 46% / 58% 44% 56% 42%;
|
||||
opacity: 0.18;
|
||||
pointer-events: none; z-index: 0;
|
||||
}
|
||||
.bb-blob-br {
|
||||
position: fixed; bottom: -80px; right: -60px;
|
||||
width: 340px; height: 340px;
|
||||
background: radial-gradient(ellipse at 55% 55%, #FFD080 0%, #F8A040 60%, #F07040 100%);
|
||||
border-radius: 44% 56% 38% 62% / 52% 48% 52% 48%;
|
||||
opacity: 0.16;
|
||||
pointer-events: none; z-index: 0;
|
||||
}
|
||||
|
||||
/* ── Canvas SVG flowers ── */
|
||||
.bb-flower { position: fixed; pointer-events: none; z-index: 0; }
|
||||
.bb-flower-tr { top: 16px; right: 24px; }
|
||||
.bb-flower-ml { top: 38%; left: -4px; }
|
||||
.bb-flower-br { bottom: 72px; right: 48px; }
|
||||
.bb-flower-bl { bottom: 88px; left: 32px; }
|
||||
|
||||
/* ── Brand fonts ── */
|
||||
.font-script { font-family: 'Great Vibes', cursive; }
|
||||
.font-pacifico { font-family: 'Pacifico', cursive; }
|
||||
.font-nunito { font-family: 'Nunito', sans-serif; }
|
||||
|
||||
/* ── Bottom nav stays above content ── */
|
||||
#bottom-nav {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #F0E8DF;
|
||||
height: 62px;
|
||||
display: flex; align-items: center; justify-content: space-around;
|
||||
z-index: 50;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
/* ── FAB inside nav ── */
|
||||
.bb-fab {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
background: linear-gradient(145deg, #F58040, #E84020);
|
||||
box-shadow: 0 4px 16px rgba(240, 80, 40, 0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 24px; line-height: 1; font-weight: 300;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Gradient CTA button ── */
|
||||
.btn-bloom {
|
||||
display: block; width: 100%;
|
||||
background: linear-gradient(135deg, #F58040, #E84020);
|
||||
color: #fff; border: none; border-radius: 50px;
|
||||
padding: 12px 24px; font-family: 'Nunito', sans-serif;
|
||||
font-weight: 800; font-size: 15px; text-align: center;
|
||||
box-shadow: 0 4px 14px rgba(240,80,40,0.4); cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-bloom-outline {
|
||||
display: block; width: 100%;
|
||||
background: transparent; color: #1A1208;
|
||||
border: 1.5px solid #E0D4C8; border-radius: 50px;
|
||||
padding: 11px 24px; font-family: 'Nunito', sans-serif;
|
||||
font-weight: 700; font-size: 15px; text-align: center;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── White pill button (hero overlays) ── */
|
||||
.btn-pill {
|
||||
background: rgba(255,255,255,0.88);
|
||||
border: none; border-radius: 50px;
|
||||
padding: 6px 14px; font-size: 13px; font-weight: 700;
|
||||
color: #1A1208; backdrop-filter: blur(4px); cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── Confidence bar ── */
|
||||
.conf-track { height: 5px; background: #F0E4D8; border-radius: 3px; overflow: hidden; }
|
||||
.conf-fill { height: 100%; background: linear-gradient(90deg, #F8A060, #E84020); border-radius: 3px; }
|
||||
|
||||
/* ── Area thumbnail gradient placeholders ── */
|
||||
.at-placeholder-1 { background: linear-gradient(135deg, #88C860, #E85878, #A0D060); }
|
||||
.at-placeholder-2 { background: linear-gradient(150deg, #B8E060, #F0A840, #E87830); }
|
||||
.at-placeholder-3 { background: linear-gradient(120deg, #F8B840, #88C060, #F0C860); }
|
||||
.at-placeholder-4 { background: linear-gradient(140deg, #D0A8E8, #68B858, #C888E0); }
|
||||
.at-placeholder-5 { background: linear-gradient(130deg, #78D8C8, #E8F0A0, #60C8A8); }
|
||||
.at-placeholder-6 { background: linear-gradient(125deg, #F8A0B0, #B8E060, #F09060); }
|
||||
|
||||
/* ── Viewfinder corners ── */
|
||||
.vf-corner { position: absolute; width: 18px; height: 18px; border-color: rgba(255,255,255,0.9); border-style: solid; }
|
||||
.vf-tl { top: 10px; left: 10px; border-width: 2.5px 0 0 2.5px; border-radius: 2px 0 0 0; }
|
||||
.vf-tr { top: 10px; right: 10px; border-width: 2.5px 2.5px 0 0; border-radius: 0 2px 0 0; }
|
||||
.vf-bl { bottom: 10px; left: 10px; border-width: 0 0 2.5px 2.5px; border-radius: 0 0 0 2px; }
|
||||
.vf-br { bottom: 10px; right: 10px; border-width: 0 2.5px 2.5px 0; border-radius: 0 0 2px 0; }
|
||||
|
|
@ -1,44 +1,161 @@
|
|||
{# plants/templates/plants/base.html #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<title>{% block title %}BloomBase{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'plants/css/bloombase.css' %}">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bloom: {
|
||||
bg: '#FFF8F2', primary: '#F07040', green: '#52B788',
|
||||
dark: '#1A1208', muted: '#B0A090', border: '#F0E4D4',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
nunito: ['Nunito', 'sans-serif'],
|
||||
script: ['"Great Vibes"', 'cursive'],
|
||||
pacifico: ['Pacifico', 'cursive'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||
<style>
|
||||
body { padding-bottom: 1rem; }
|
||||
.plant-hero { width: 100%; height: 220px; object-fit: cover; }
|
||||
.plant-hero-placeholder { width: 100%; height: 220px; background: #d8f3dc; display: flex; align-items: center; justify-content: center; color: #52b788; font-size: 3rem; }
|
||||
.care-chip { display: inline-block; padding: .2rem .55rem; border-radius: 1rem; font-size: .8rem; margin: .15rem; }
|
||||
.pruning-strip { border-left: 4px solid #ffc107; background: #fff9c4; padding: .5rem .75rem; border-radius: 0 .375rem .375rem 0; }
|
||||
.pruning-strip.overdue { border-color: #dc3545; background: #fff5f5; }
|
||||
/* Month strip — display */
|
||||
.month-strip { display: flex; gap: 3px; }
|
||||
.month-dot { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; font-size: 10px; font-weight: 700; letter-spacing: 0; background: #e9ecef; color: #adb5bd; }
|
||||
.month-dot--on { background: #2d6a4f; color: #fff; }
|
||||
/* Month toggle — form */
|
||||
.month-toggle-group { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.month-toggle { position: relative; }
|
||||
.month-toggle input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
|
||||
.month-toggle label { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; font-size: 13px; font-weight: 700; cursor: pointer; background: #e9ecef; color: #6c757d; transition: background .15s, color .15s; user-select: none; }
|
||||
.month-toggle input:checked + label { background: #2d6a4f; color: #fff; }
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<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>
|
||||
<body class="bg-[#FFF8F2]">
|
||||
|
||||
<!-- Canvas background blobs -->
|
||||
<div class="bb-blob-tl"></div>
|
||||
<div class="bb-blob-br"></div>
|
||||
|
||||
<!-- Canvas SVG flowers -->
|
||||
<svg class="bb-flower bb-flower-tr" width="140" height="140" viewBox="0 0 140 140" xmlns="http://www.w3.org/2000/svg" opacity="0.85">
|
||||
<defs>
|
||||
<radialGradient id="hp1" cx="50%" cy="80%" r="85%"><stop offset="0%" stop-color="#A02848"/><stop offset="35%" stop-color="#D04870"/><stop offset="70%" stop-color="#F07898"/><stop offset="100%" stop-color="#FFCCD8"/></radialGradient>
|
||||
<radialGradient id="hp2" cx="50%" cy="80%" r="85%"><stop offset="0%" stop-color="#B83060"/><stop offset="35%" stop-color="#E06080"/><stop offset="70%" stop-color="#F898B0"/><stop offset="100%" stop-color="#FFDDEA"/></radialGradient>
|
||||
</defs>
|
||||
<g transform="translate(70,70)">
|
||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(0)"/>
|
||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp2)" transform="rotate(72)" opacity="0.92"/>
|
||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(144)" opacity="0.88"/>
|
||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp2)" transform="rotate(216)" opacity="0.93"/>
|
||||
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(288)" opacity="0.87"/>
|
||||
<rect x="-3.5" y="-20" width="7" height="20" rx="3.5" fill="#FFD060"/>
|
||||
<circle r="6" fill="#FFD040"/><circle r="3" fill="#E84020"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<svg class="bb-flower bb-flower-ml" width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" opacity="0.7">
|
||||
<defs><radialGradient id="pp" cx="50%" cy="80%" r="80%"><stop offset="0%" stop-color="#C03058"/><stop offset="50%" stop-color="#E86090"/><stop offset="100%" stop-color="#FFB8CC"/></radialGradient></defs>
|
||||
<g transform="translate(36,36)">
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(0)"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(72)" opacity="0.9"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(144)"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(216)" opacity="0.9"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(288)"/>
|
||||
<circle r="5" fill="#FFD040"/><circle r="2.5" fill="#E84020"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<svg class="bb-flower bb-flower-br" width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg" opacity="0.8">
|
||||
<defs><radialGradient id="yp" cx="50%" cy="80%" r="80%"><stop offset="0%" stop-color="#D08000"/><stop offset="50%" stop-color="#F0C020"/><stop offset="100%" stop-color="#FFE880"/></radialGradient></defs>
|
||||
<g transform="translate(48,48)">
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(0)"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(45)" opacity="0.9"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(90)"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(135)" opacity="0.9"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(180)"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(225)" opacity="0.9"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(270)"/>
|
||||
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(315)" opacity="0.9"/>
|
||||
<circle r="9" fill="#E06010"/><circle r="5" fill="#C04000"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<svg class="bb-flower bb-flower-bl" width="56" height="56" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg" opacity="0.65">
|
||||
<g transform="translate(28,28)">
|
||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(0)"/>
|
||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(60)"/>
|
||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(120)"/>
|
||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(180)"/>
|
||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(240)"/>
|
||||
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(300)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Page content — pb-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>
|
||||
|
||||
<div class="container-fluid px-3 pt-3">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,71 +1,95 @@
|
|||
{# plants/templates/plants/dashboard.html #}
|
||||
{% extends "plants/base.html" %}
|
||||
{% load plant_extras %}
|
||||
{% block title %}Dashboard — BloomBase{% endblock %}
|
||||
{% block title %}BloomBase{% endblock %}
|
||||
{% block content %}
|
||||
<h6 class="text-muted mb-3">{{ today|date:"F Y" }}</h6>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<a href="{% url 'plant_list' %}" class="text-decoration-none">
|
||||
<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;">{{ total_plants }}</div>
|
||||
<div class="small" style="color:#2d6a4f;">Plants</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<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>
|
||||
<!-- Top bar: hamburger + bell -->
|
||||
<div class="flex justify-between items-center px-4 pt-6 pb-2">
|
||||
<button class="p-1" aria-label="Menu">
|
||||
<svg width="22" height="22" fill="none" stroke="#1A1208" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M3 12h18M3 6h18M3 18h18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="p-1" aria-label="Notifications">
|
||||
<svg width="22" height="22" fill="none" stroke="#1A1208" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if blooming_now %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-semibold">🌸 Blooming this month</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for plant in blooming_now %}
|
||||
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ plant.name }}</div>
|
||||
<small class="text-muted">{{ plant.location }}</small>
|
||||
</div>
|
||||
<div>{% month_strip plant.bloom_months %}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="px-4">
|
||||
|
||||
{% if latest_plants %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-semibold">🌿 Recently added</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<!-- Greeting + headline -->
|
||||
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-0.5">Hello, Plant Lover! 🌿</p>
|
||||
<h1 class="font-nunito font-black text-[24px] text-[#1A1208] leading-tight mb-1">
|
||||
What are we <span class="text-[#F07040]">growing</span> today?
|
||||
</h1>
|
||||
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
|
||||
Identify a plant and add it to Bloombase.
|
||||
</p>
|
||||
|
||||
<!-- Camera viewfinder — tap → identify_upload -->
|
||||
<a href="{% url 'identify_upload' %}" class="block relative bg-[#2C3820] rounded-2xl h-[120px] overflow-hidden mb-4 no-underline">
|
||||
<!-- Simulated plant in viewfinder -->
|
||||
<div class="absolute inset-0"
|
||||
style="background: radial-gradient(ellipse 60% 55% at 60% 45%, #C84060 0%, #E05878 30%, #F880A0 55%, transparent 75%),
|
||||
radial-gradient(ellipse 40% 35% at 40% 55%, #C04050 0%, #D86070 40%, transparent 70%),
|
||||
linear-gradient(160deg, #1E3018 0%, #2E5028 30%, #3A6832 50%, #2C5020 70%);"></div>
|
||||
<!-- Corner brackets -->
|
||||
<div class="vf-corner vf-tl"></div>
|
||||
<div class="vf-corner vf-tr"></div>
|
||||
<div class="vf-corner vf-bl"></div>
|
||||
<div class="vf-corner vf-br"></div>
|
||||
<!-- Shutter + gallery -->
|
||||
<div class="absolute bottom-3 left-0 right-0 flex justify-center items-center gap-6">
|
||||
<div class="w-8 h-8 bg-white/20 rounded-xl flex items-center justify-center border border-white/40">
|
||||
<svg width="14" height="14" fill="none" stroke="white" stroke-width="2" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bb-fab"></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- My Garden pill -->
|
||||
<a href="{% url 'location_list' %}"
|
||||
class="flex items-center gap-3 bg-white/80 rounded-2xl px-4 py-3 border border-[#F0E4D4] mb-5 no-underline">
|
||||
<span class="text-2xl">🪴</span>
|
||||
<div class="flex-1">
|
||||
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">My Garden</div>
|
||||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090]">
|
||||
{{ total_plants }} Plant{{ total_plants|pluralize }} · {{ location_count }} Area{{ location_count|pluralize }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[#D0C0B0] font-bold text-lg">›</span>
|
||||
</a>
|
||||
|
||||
<!-- Recently added -->
|
||||
{% if latest_plants %}
|
||||
<h2 class="font-nunito font-black text-[16px] text-[#1A1208] mb-3">🌿 Recently added</h2>
|
||||
<div class="flex flex-col gap-2 mb-4">
|
||||
{% for plant in latest_plants %}
|
||||
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2 text-decoration-none">
|
||||
<a href="{% url 'plant_detail' plant.pk %}"
|
||||
class="flex items-center gap-3 bg-white rounded-2xl px-3 py-2.5 shadow-sm no-underline">
|
||||
{% if plant.species and plant.species.api_image_url %}
|
||||
<img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover;flex-shrink:0;">
|
||||
<img src="{{ plant.species.api_image_url }}" width="44" height="44"
|
||||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
||||
{% elif plant.thumbnail_url %}
|
||||
<img src="{{ plant.thumbnail_url }}" width="48" height="48" class="rounded" style="object-fit:cover;flex-shrink:0;">
|
||||
<img src="{{ plant.thumbnail_url }}" width="44" height="44"
|
||||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
||||
{% else %}
|
||||
<div style="width:48px;height:48px;background:#d8f3dc;border-radius:.375rem;flex-shrink:0;display:flex;align-items:center;justify-content:center;">🌿</div>
|
||||
<div class="w-11 h-11 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-xl">🌿</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div>
|
||||
<small class="text-muted">{{ plant.location }}</small>
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="d-grid">
|
||||
<a href="{% url 'plant_add' %}" class="btn btn-success">+ Add a plant</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,62 +1,109 @@
|
|||
{# plants/templates/plants/identify_confirm.html #}
|
||||
{% extends "plants/base.html" %}
|
||||
{% block title %}Confirm species — BloomBase{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<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="px-4 pt-6">
|
||||
|
||||
<p class="text-muted small mb-3">Tap the correct species, then confirm.</p>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<a href="{% url 'identify_upload' %}" class="btn-pill no-underline text-sm">←</a>
|
||||
<h1 class="font-nunito font-black text-[20px] text-[#1A1208]">Is this your plant?</h1>
|
||||
</div>
|
||||
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
|
||||
Tap the correct species, then confirm.
|
||||
</p>
|
||||
|
||||
<form method="post" id="confirm-form">
|
||||
<form method="post" id="confirm-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="match_idx" id="match_idx_input" value="0">
|
||||
|
||||
<div class="d-flex flex-column gap-2 mb-4" id="match-list">
|
||||
<div class="flex flex-col gap-3 mb-5" id="match-list">
|
||||
{% for m in matches %}
|
||||
<div class="match-card d-flex align-items-center gap-3 p-3 border rounded"
|
||||
style="cursor:pointer; {% if forloop.first %}border-color:#52b788 !important; background:#f0faf4;{% endif %}"
|
||||
<div class="match-card flex items-center gap-3 bg-white rounded-2xl p-3 shadow-sm cursor-pointer
|
||||
{% if forloop.first %}ring-2 ring-[#F07040]{% endif %}"
|
||||
data-idx="{{ forloop.counter0 }}"
|
||||
data-name="{{ m.scientific_name }}"
|
||||
onclick="selectMatch(this)">
|
||||
{% if m.image_url %}
|
||||
<img src="{{ m.image_url }}" alt="{{ m.scientific_name }}"
|
||||
style="width:64px;height:64px;object-fit:cover;border-radius:8px;flex-shrink:0;">
|
||||
class="w-16 h-16 rounded-xl flex-shrink-0 object-cover">
|
||||
{% else %}
|
||||
<div style="width:64px;height:64px;background:#e9ecef;border-radius:8px;flex-shrink:0;"></div>
|
||||
<div class="w-16 h-16 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-2xl">🌿</div>
|
||||
{% endif %}
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold">{{ m.scientific_name }}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ m.scientific_name }}</div>
|
||||
{% if m.common_names %}
|
||||
<div class="text-muted small">{{ m.common_names|join:", " }}</div>
|
||||
{% endif %}
|
||||
<div class="text-muted small fst-italic">{{ m.family }}</div>
|
||||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] mt-0.5 truncate">
|
||||
{{ m.common_names|join:", " }}
|
||||
</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 %}">
|
||||
{% endif %}
|
||||
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] italic">{{ m.family }}</div>
|
||||
</div>
|
||||
<span class="font-nunito font-bold text-[11px] px-2 py-1 rounded-full flex-shrink-0
|
||||
{% if m.score >= 0.70 %}bg-green-100 text-green-700
|
||||
{% elif m.score >= 0.30 %}bg-orange-100 text-orange-700
|
||||
{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||
{% widthratio m.score 1 100 %}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success w-100" id="confirm-btn">
|
||||
Use <span id="selected-name">{{ matches.0.scientific_name }}</span> →
|
||||
<!-- 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn-bloom">
|
||||
Add to my garden →
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectMatch(el) {
|
||||
document.querySelectorAll('.match-card').forEach(c => {
|
||||
c.style.borderColor = '';
|
||||
c.style.background = '';
|
||||
function selectMatch(card) {
|
||||
document.querySelectorAll('.match-card').forEach(c => c.classList.remove('ring-2', 'ring-[#F07040]'));
|
||||
card.classList.add('ring-2', 'ring-[#F07040]');
|
||||
document.getElementById('match_idx_input').value = card.dataset.idx;
|
||||
const nameInput = document.getElementById('plant-name-input');
|
||||
if (!nameInput.value || nameInput.dataset.autoSet) {
|
||||
nameInput.value = card.dataset.name;
|
||||
nameInput.dataset.autoSet = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function selectLocation(chip) {
|
||||
document.querySelectorAll('.location-chip').forEach(c => {
|
||||
c.classList.remove('bg-[#F07040]', 'text-white');
|
||||
c.classList.add('bg-[#F5F0EA]', 'text-[#B0A090]');
|
||||
});
|
||||
el.style.borderColor = '#52b788';
|
||||
el.style.background = '#f0faf4';
|
||||
document.getElementById('match_idx_input').value = el.dataset.idx;
|
||||
document.getElementById('selected-name').textContent = el.dataset.name;
|
||||
chip.classList.remove('bg-[#F5F0EA]', 'text-[#B0A090]');
|
||||
chip.classList.add('bg-[#F07040]', 'text-white');
|
||||
document.getElementById('location-id-input').value = chip.dataset.id;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -17,21 +17,16 @@
|
|||
<div class="text-muted" style="font-size:11px;">vasteplantencatalogus.nl</div>
|
||||
</div>
|
||||
{% if vpc_results|length > 1 %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
change
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<form method="get" action="{% url 'identify_fields' %}">
|
||||
<select name="vpc_idx" onchange="this.form.submit()"
|
||||
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]">
|
||||
{% for r in vpc_results %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if forloop.counter0 == vpc_idx %}active{% endif %}"
|
||||
href="{% url 'identify_fields' %}?vpc_idx={{ forloop.counter0 }}">
|
||||
<option value="{{ forloop.counter0 }}" {% if forloop.counter0 == vpc_idx %}selected{% endif %}>
|
||||
{{ r.common_name }}
|
||||
</a>
|
||||
</li>
|
||||
</option>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,96 @@
|
|||
{# plants/templates/plants/identify_upload.html #}
|
||||
{% extends "plants/base.html" %}
|
||||
{% block title %}Identify plant — BloomBase{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<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>
|
||||
<div class="px-4 pt-6">
|
||||
|
||||
<p class="text-muted small mb-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<a href="{% url 'dashboard' %}" class="btn-pill no-underline text-sm">←</a>
|
||||
<h1 class="font-nunito font-black text-[20px] text-[#1A1208]">Identify plant</h1>
|
||||
</div>
|
||||
|
||||
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
|
||||
Add one or more photos, tag what each shows, then tap Identify.
|
||||
</p>
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-warning py-2 small mb-3">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-2xl px-4 py-3 font-nunito text-sm text-orange-700 mb-4">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" id="identify-form">
|
||||
<form method="post" enctype="multipart/form-data" id="identify-form">
|
||||
{% csrf_token %}
|
||||
<input type="file" name="images" accept="image/jpeg,image/png" class="d-none" id="file-picker">
|
||||
<input type="file" name="images" accept="image/jpeg,image/png" class="hidden" id="file-picker">
|
||||
|
||||
<div id="photo-rows" class="mb-3"></div>
|
||||
<!-- Viewfinder / drop zone -->
|
||||
<div class="relative bg-[#2C3820] rounded-2xl h-[140px] overflow-hidden mb-4" id="vf-box">
|
||||
<div class="absolute inset-0"
|
||||
style="background: radial-gradient(ellipse 60% 55% at 60% 45%, #C84060 0%, #E05878 30%, #F880A0 55%, transparent 75%),
|
||||
linear-gradient(160deg, #1E3018 0%, #2E5028 30%, #3A6832 50%, #2C5020 70%);"></div>
|
||||
<div class="vf-corner vf-tl"></div>
|
||||
<div class="vf-corner vf-tr"></div>
|
||||
<div class="vf-corner vf-bl"></div>
|
||||
<div class="vf-corner vf-br"></div>
|
||||
<!-- Controls row -->
|
||||
<div class="absolute bottom-4 left-0 right-0 flex justify-center items-center gap-6">
|
||||
<label for="file-picker"
|
||||
class="w-9 h-9 bg-white/20 rounded-xl flex items-center justify-center border border-white/40 cursor-pointer">
|
||||
<svg width="15" height="15" fill="none" stroke="white" stroke-width="2" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</label>
|
||||
<label for="file-picker"
|
||||
class="bb-fab cursor-pointer">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="add-btn-wrap" class="mb-3">
|
||||
<label class="btn btn-outline-secondary w-100 py-3" for="file-picker" id="add-photo-label">
|
||||
📷 Add photo <span id="photo-count" class="text-muted small"></span>
|
||||
<!-- Photo rows (populated by JS) -->
|
||||
<div id="photo-rows" class="mb-4"></div>
|
||||
|
||||
<!-- Add more label (shown when < 5 photos) -->
|
||||
<div id="add-btn-wrap" class="mb-4">
|
||||
<label for="file-picker"
|
||||
class="flex items-center justify-center gap-2 bg-white border border-[#F0E4D4] rounded-2xl py-3
|
||||
font-nunito font-bold text-sm text-[#B0A090] cursor-pointer">
|
||||
📷 Add photo <span id="photo-count" class="text-[#C0B0A0]"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success w-100" id="identify-btn" disabled>
|
||||
<button type="submit" id="identify-btn" disabled
|
||||
class="btn-bloom opacity-50 cursor-not-allowed">
|
||||
Identify →
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{% url 'plant_add' %}" class="text-muted small">Or enter name manually</a>
|
||||
</div>
|
||||
<div class="text-center mt-4">
|
||||
<a href="{% url 'plant_add' %}" class="font-nunito text-sm font-semibold text-[#B0A090] no-underline">
|
||||
Or enter name manually
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div id="identify-spinner"
|
||||
style="display:none; position:fixed; inset:0; background:rgba(255,248,242,.92); z-index:9999;
|
||||
align-items:center; justify-content:center; flex-direction:column; gap:16px;">
|
||||
<div style="width:40px;height:40px;border:3px solid #F0E4D4;border-top-color:#F07040;
|
||||
border-radius:50%;animation:spin .8s linear infinite;"></div>
|
||||
<p class="font-nunito font-bold text-sm text-[#B0A090]">Identifying plant…</p>
|
||||
</div>
|
||||
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
||||
|
||||
<div 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>
|
||||
|
||||
<script>
|
||||
const ORGANS = [
|
||||
{val: 'flower', label: '🌸 flower'},
|
||||
{val: 'leaf', label: '🍃 leaf'},
|
||||
{val: 'habit', label: '🌿 habit'},
|
||||
{val: 'fruit', label: '🍎 fruit'},
|
||||
{val: 'bark', label: '🪵 bark'},
|
||||
{val: 'auto', label: '✨ auto'},
|
||||
{val:'flower',label:'🌸 flower'},{val:'leaf',label:'🍃 leaf'},
|
||||
{val:'habit',label:'🌿 habit'},{val:'fruit',label:'🍎 fruit'},
|
||||
{val:'bark',label:'🪵 bark'},{val:'auto',label:'✨ auto'},
|
||||
];
|
||||
const selected = []; // {file, organ, objectUrl}
|
||||
const selected = [];
|
||||
const picker = document.getElementById('file-picker');
|
||||
|
||||
picker.addEventListener('change', function () {
|
||||
|
|
@ -60,10 +100,7 @@ picker.addEventListener('change', function () {
|
|||
render();
|
||||
});
|
||||
|
||||
function setOrgan(idx, organ) {
|
||||
selected[idx].organ = organ;
|
||||
render();
|
||||
}
|
||||
function setOrgan(idx, organ) { selected[idx].organ = organ; render(); }
|
||||
|
||||
function removePhoto(idx) {
|
||||
URL.revokeObjectURL(selected[idx].objectUrl);
|
||||
|
|
@ -76,38 +113,43 @@ function render() {
|
|||
rows.innerHTML = '';
|
||||
selected.forEach((item, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'd-flex gap-2 align-items-start mb-3';
|
||||
row.className = 'flex gap-3 items-start mb-4';
|
||||
row.innerHTML = `
|
||||
<div style="position:relative;flex-shrink:0;">
|
||||
<img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:8px;" alt="">
|
||||
<img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:14px;">
|
||||
<button type="button" onclick="removePhoto(${i})"
|
||||
style="position:absolute;top:2px;right:2px;background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:50%;width:20px;height:20px;font-size:10px;cursor:pointer;padding:0;line-height:1;">✕</button>
|
||||
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.5);color:#fff;
|
||||
border:none;border-radius:50%;width:20px;height:20px;font-size:10px;
|
||||
cursor:pointer;padding:0;line-height:1;">✕</button>
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<div class="text-muted small mb-1">What's shown?</div>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
${ORGANS.map(o => `
|
||||
<span onclick="setOrgan(${i}, '${o.val}')"
|
||||
style="cursor:pointer; padding:2px 8px; border-radius:10px; font-size:11px; ${item.organ === o.val ? 'background:#52b788;color:#fff;' : 'background:#e9ecef;color:#6c757d;'}">
|
||||
<div style="font-family:Nunito,sans-serif;font-size:11px;font-weight:700;color:#B0A090;margin-bottom:6px;">What's shown?</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px;">
|
||||
${ORGANS.map(o=>`
|
||||
<span onclick="setOrgan(${i},'${o.val}')"
|
||||
style="cursor:pointer;padding:3px 10px;border-radius:20px;font-size:11px;font-family:Nunito,sans-serif;font-weight:700;
|
||||
${item.organ===o.val?'background:#F07040;color:#fff;':'background:#F5F0EA;color:#B0A090;'}">
|
||||
${o.label}
|
||||
</span>
|
||||
`).join('')}
|
||||
</span>`).join('')}
|
||||
</div>
|
||||
<input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
|
||||
</div>`;
|
||||
rows.appendChild(row);
|
||||
});
|
||||
|
||||
document.querySelectorAll('input[name="organs"]').forEach((el, i) => {
|
||||
if (selected[i]) el.value = selected[i].organ;
|
||||
document.querySelectorAll('input[name="organs"]').forEach((el,i)=>{
|
||||
if(selected[i]) el.value=selected[i].organ;
|
||||
});
|
||||
|
||||
document.getElementById('photo-count').textContent = selected.length ? `(${selected.length}/5)` : '';
|
||||
document.getElementById('add-btn-wrap').style.display = selected.length >= 5 ? 'none' : '';
|
||||
document.getElementById('identify-btn').disabled = selected.length === 0;
|
||||
const btn = document.getElementById('identify-btn');
|
||||
btn.disabled = selected.length === 0;
|
||||
btn.style.opacity = selected.length === 0 ? '0.5' : '1';
|
||||
btn.style.cursor = selected.length === 0 ? 'not-allowed' : 'pointer';
|
||||
}
|
||||
|
||||
document.getElementById('identify-form').addEventListener('submit', function (e) {
|
||||
document.getElementById('identify-form').addEventListener('submit', function(e) {
|
||||
if (selected.length === 0) { e.preventDefault(); return; }
|
||||
const dt = new DataTransfer();
|
||||
selected.forEach(s => dt.items.add(s.file));
|
||||
|
|
|
|||
67
plants/templates/plants/location_detail.html
Normal file
67
plants/templates/plants/location_detail.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{# plants/templates/plants/location_detail.html #}
|
||||
{% extends "plants/base.html" %}
|
||||
{% block title %}{{ location.name }} — BloomBase{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Cover photo hero -->
|
||||
<div class="relative h-[180px] overflow-hidden">
|
||||
{% if location.cover_photo %}
|
||||
<img src="{{ location.cover_photo.url }}" alt="{{ location.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full at-placeholder-1"></div>
|
||||
{% endif %}
|
||||
<!-- Gradient fade at bottom -->
|
||||
<div class="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-white to-transparent"></div>
|
||||
<!-- Back button -->
|
||||
<a href="{% url 'location_list' %}" class="btn-pill absolute top-3 left-3 no-underline">← Back</a>
|
||||
<!-- Edit button -->
|
||||
<a href="{% url 'location_edit' location.pk %}" class="btn-pill absolute top-3 right-3 no-underline">Edit</a>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pt-3 pb-4">
|
||||
|
||||
<!-- Area name + count -->
|
||||
<h1 class="font-nunito font-black text-[22px] text-[#1A1208] mb-0.5">{{ location.name }}</h1>
|
||||
<p class="font-nunito text-sm font-semibold text-[#B0A090] mb-5">
|
||||
{{ plants.count }} plant{{ plants.count|pluralize }}
|
||||
</p>
|
||||
|
||||
<!-- Plant grid -->
|
||||
{% if plants %}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{% for plant in plants %}
|
||||
<a href="{% url 'plant_detail' plant.pk %}" class="bg-white rounded-2xl overflow-hidden shadow-sm no-underline block">
|
||||
<!-- Plant image -->
|
||||
<div class="h-[100px] overflow-hidden">
|
||||
{% if plant.species and plant.species.api_image_url %}
|
||||
<img src="{{ plant.species.api_image_url }}" alt="{{ plant.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% elif plant.thumbnail_url %}
|
||||
<img src="{{ plant.thumbnail_url }}" alt="{{ plant.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full bg-[#D8F3DC] flex items-center justify-center text-3xl">🌿</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Plant info -->
|
||||
<div class="p-3">
|
||||
<div class="font-nunito font-extrabold text-[12px] text-[#1A1208] leading-tight">{{ plant.name }}</div>
|
||||
{% if plant.species %}
|
||||
<div class="font-nunito text-[9px] font-semibold text-[#B0A090] mt-0.5 italic">{{ plant.species.scientific_name }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Empty state -->
|
||||
<div class="text-center py-12">
|
||||
<div class="text-5xl mb-3">🪴</div>
|
||||
<p class="font-nunito font-bold text-[#B0A090] mb-4">No plants here yet.</p>
|
||||
<a href="{% url 'identify_upload' %}" class="btn-bloom inline-block w-auto px-8">Identify a plant</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,21 +1,59 @@
|
|||
{# plants/templates/plants/location_edit.html #}
|
||||
{% extends "plants/base.html" %}
|
||||
{% block title %}Edit Area — BloomBase{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<a href="{% url 'location_list' %}" class="btn btn-sm btn-outline-secondary">←</a>
|
||||
<h5 class="mb-0 flex-grow-1">Edit area</h5>
|
||||
</div>
|
||||
<div class="px-4 pt-6 pb-4">
|
||||
|
||||
<form method="post">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="{% url 'location_list' %}" class="btn-pill no-underline text-sm">←</a>
|
||||
<h1 class="font-nunito font-black text-[20px] text-[#1A1208]">Edit area</h1>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="flex flex-col gap-4">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<input type="text" name="name" value="{{ location.name }}" class="form-control" required autofocus>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">Save</button>
|
||||
<a href="{% url 'location_list' %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Area name -->
|
||||
<div>
|
||||
<label class="block font-nunito font-bold text-[11px] text-[#B0A090] uppercase tracking-wider mb-1.5">
|
||||
Area name
|
||||
</label>
|
||||
<input type="text" name="name" value="{{ location.name }}" required autofocus
|
||||
class="w-full bg-white border border-[#F0E4D4] rounded-2xl px-4 py-3
|
||||
font-nunito text-[15px] text-[#1A1208] outline-none focus:border-[#F07040]">
|
||||
</div>
|
||||
|
||||
<!-- Cover photo -->
|
||||
<div>
|
||||
<label class="block font-nunito font-bold text-[11px] text-[#B0A090] uppercase tracking-wider mb-1.5">
|
||||
Cover photo
|
||||
</label>
|
||||
{% if location.cover_photo %}
|
||||
<div class="mb-2 rounded-2xl overflow-hidden h-[100px]">
|
||||
<img src="{{ location.cover_photo.url }}" alt="Current cover"
|
||||
class="w-full h-full object-cover">
|
||||
</div>
|
||||
{% endif %}
|
||||
<label class="flex items-center gap-3 bg-white border border-[#F0E4D4] rounded-2xl px-4 py-3 cursor-pointer">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#F07040" stroke-width="2">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
<span class="font-nunito font-semibold text-sm text-[#B0A090]">
|
||||
{% if location.cover_photo %}Replace photo{% else %}Add cover photo{% endif %}
|
||||
</span>
|
||||
<input type="file" name="cover_photo" accept="image/*" class="hidden" id="cover-input"
|
||||
onchange="document.getElementById('cover-label').textContent = this.files[0]?.name || ''">
|
||||
</label>
|
||||
<span id="cover-label" class="block mt-1 font-nunito text-[10px] text-[#B0A090] px-1"></span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<button type="submit" class="btn-bloom">Save</button>
|
||||
<a href="{% url 'location_list' %}" class="btn-bloom-outline text-center no-underline">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,72 @@
|
|||
{# plants/templates/plants/locations.html #}
|
||||
{% extends "plants/base.html" %}
|
||||
{% block title %}Areas — BloomBase{% endblock %}
|
||||
{% block title %}My Garden — BloomBase{% endblock %}
|
||||
{% block content %}
|
||||
{% load static %}
|
||||
|
||||
<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 class="px-4 pt-8 pb-4">
|
||||
|
||||
<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 %}">
|
||||
<!-- 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 %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-2">✕</button>
|
||||
<input type="text" name="name" placeholder="New area name…" required
|
||||
class="flex-1 bg-white border border-[#F0E4D4] rounded-full px-4 py-2.5
|
||||
font-nunito text-sm text-[#1A1208] placeholder-[#C0B0A0]
|
||||
outline-none focus:border-[#F07040]">
|
||||
<button type="submit" class="bb-fab text-xl font-light flex-shrink-0">+</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">No areas yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex gap-2">
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="New area name" required>
|
||||
<button type="submit" class="btn btn-sm btn-success text-nowrap">+ Add</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,35 @@
|
|||
<div class="mb-3">
|
||||
<h6 class="mb-2">Photos</h6>
|
||||
<div class="mb-4">
|
||||
<h6 class="font-nunito font-bold text-sm text-[#1A1208] mb-2">Photos</h6>
|
||||
|
||||
{% with photos=plant.photos.all %}
|
||||
{% if photos %}
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{% for photo in photos %}
|
||||
<div class="position-relative">
|
||||
<div class="relative">
|
||||
<img src="{{ photo.image.url }}" width="80" height="80"
|
||||
class="rounded {% if photo.is_thumbnail %}border border-success border-2{% endif %}"
|
||||
class="rounded-lg object-cover {% if photo.is_thumbnail %}ring-2 ring-[#52B788]{% endif %}"
|
||||
style="object-fit:cover;" alt="">
|
||||
<div class="d-flex gap-1 mt-1">
|
||||
<div class="flex gap-1 mt-1">
|
||||
{% if not photo.is_thumbnail %}
|
||||
<form method="post" action="{% url 'set_thumbnail' photo.pk %}"
|
||||
hx-post="{% url 'set_thumbnail' photo.pk %}"
|
||||
hx-target="#photo-gallery" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-success btn-sm py-0 px-1" style="font-size:.7rem;" title="Set as thumbnail">★</button>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center w-6 h-6 rounded text-[#52B788] border border-[#52B788] hover:bg-[#E8F4EC] text-xs leading-none"
|
||||
title="Set as thumbnail">★</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="btn btn-success btn-sm py-0 px-1 disabled" style="font-size:.7rem;" title="Thumbnail">★</span>
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded bg-[#52B788] text-white text-xs leading-none cursor-default"
|
||||
title="Thumbnail">★</span>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'delete_photo' photo.pk %}"
|
||||
hx-post="{% url 'delete_photo' photo.pk %}"
|
||||
hx-target="#photo-gallery" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm py-0 px-1" style="font-size:.7rem;" title="Delete">✕</button>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center w-6 h-6 rounded text-[#F07040] border border-[#F07040] hover:bg-orange-50 text-xs leading-none"
|
||||
title="Delete">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -37,13 +42,13 @@
|
|||
hx-post="{% url 'upload_photo' plant.pk %}" hx-target="#photo-gallery" hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data" hx-indicator="#photo-upload-spinner">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<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="d-none"
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<label class="btn-bloom-outline text-xs px-3 py-1 cursor-pointer" for="photo-upload-input">📷 Choose photo</label>
|
||||
<input type="file" id="photo-upload-input" name="image" accept="image/*" class="hidden"
|
||||
onchange="document.getElementById('photo-filename').textContent = this.files[0]?.name || ''">
|
||||
<span id="photo-filename" class="text-muted small"></span>
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Upload</button>
|
||||
<span id="photo-upload-spinner" class="htmx-indicator text-muted small">Uploading…</span>
|
||||
<span id="photo-filename" class="text-xs text-[#B0A090]"></span>
|
||||
<button type="submit" class="btn-bloom-outline text-xs px-3 py-1">Upload</button>
|
||||
<span id="photo-upload-spinner" class="htmx-indicator text-xs text-[#B0A090]">Uploading…</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
{# plants/templates/plants/partials/plant_list_results.html #}
|
||||
{% for plant in plants %}
|
||||
<a href="{% url 'plant_detail' plant.pk %}" class="text-decoration-none">
|
||||
<div class="card mb-2">
|
||||
<div class="card-body d-flex align-items-center gap-3 py-2">
|
||||
<a href="{% url 'plant_detail' plant.pk %}"
|
||||
class="flex items-center gap-3 bg-white rounded-2xl px-3 py-2.5 shadow-sm mb-2.5 no-underline block">
|
||||
{% if plant.species and plant.species.api_image_url %}
|
||||
<img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
|
||||
<img src="{{ plant.species.api_image_url }}" width="52" height="52"
|
||||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
||||
{% elif plant.thumbnail_url %}
|
||||
<img src="{{ plant.thumbnail_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
|
||||
<img src="{{ plant.thumbnail_url }}" width="52" height="52"
|
||||
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
|
||||
{% else %}
|
||||
<div style="width:48px; height:48px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div>
|
||||
<div class="w-[52px] h-[52px] bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-2xl">🌿</div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-nunito font-extrabold text-[14px] text-[#1A1208] truncate">{{ plant.name }}</div>
|
||||
{% if plant.location %}
|
||||
<span class="inline-block font-nunito text-[10px] font-bold px-2 py-0.5 rounded-full bg-[#FFF3E8] text-[#F07040] mt-0.5">
|
||||
{{ plant.location.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div>
|
||||
<small class="text-muted">
|
||||
{{ plant.location }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[#D0C0B0] font-bold text-lg">›</span>
|
||||
</a>
|
||||
{% empty %}
|
||||
<p class="text-muted text-center mt-4">No plants found.</p>
|
||||
<p class="font-nunito text-sm text-[#B0A090] text-center py-8">No plants found.</p>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
{% load plant_extras %}
|
||||
<div id="pruning-strip" class="pruning-strip {% if pruning_status == 'overdue' %}overdue{% endif %} mb-3 rounded-end">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<div id="pruning-strip" class="pruning-strip {% if pruning_status == 'overdue' %}overdue{% endif %} mb-4 p-3 rounded-2xl bg-[#E8F4EC]">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<div>
|
||||
<strong>✂️ Pruning</strong>
|
||||
<p class="font-nunito font-bold text-[11px] text-[#1b4332] mb-1">✂️ Pruning months</p>
|
||||
{% if plant.pruning_months %}
|
||||
<div class="mt-1">{% month_strip plant.pruning_months %}</div>
|
||||
{% endif %}
|
||||
{% with plant.pruning_logs.first as last_log %}
|
||||
{% if last_log %}
|
||||
<div class="small text-muted mt-1 text-nowrap">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div>
|
||||
<div class="text-xs text-[#B0A090] mt-1 whitespace-nowrap">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<div class="shrink-0">
|
||||
{% if pruning_status == 'overdue' %}
|
||||
<span class="badge bg-danger">Overdue</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-red-100 text-red-700">Overdue</span>
|
||||
{% elif pruning_status == 'due_this_month' %}
|
||||
<span class="badge bg-warning text-dark">Due this month</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-yellow-100 text-yellow-800">Due this month</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -28,9 +28,9 @@
|
|||
class="mt-2"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<div class="d-flex gap-2 align-items-end flex-wrap">
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
{{ log_form.pruned_on }}
|
||||
<button type="submit" class="btn btn-sm btn-success">Log pruning</button>
|
||||
<button type="submit" class="btn-bloom-outline text-xs px-3 py-1">Log pruning</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,109 +1,131 @@
|
|||
{# plants/templates/plants/plant_detail.html #}
|
||||
{% extends "plants/base.html" %}
|
||||
{% load plant_extras %}
|
||||
{% block title %}{{ plant.name }} — BloomBase{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary">←</a>
|
||||
<h5 class="mb-0 flex-grow-1">{{ plant.name }}</h5>
|
||||
<a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a>
|
||||
<!-- Hero image -->
|
||||
<div class="relative h-[220px] overflow-hidden" id="plant-hero-container">
|
||||
{% if plant.species and plant.species.api_image_url %}
|
||||
<img src="{{ plant.species.api_image_url }}" alt="{{ plant.name }}" class="w-full h-full object-cover">
|
||||
{% elif plant.thumbnail_url %}
|
||||
<img src="{{ plant.thumbnail_url }}" alt="{{ plant.name }}" class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full bg-[#D8F3DC] flex items-center justify-center text-6xl">🌿</div>
|
||||
{% endif %}
|
||||
<div class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white to-transparent"></div>
|
||||
<!-- Back -->
|
||||
<a href="{% url 'plant_list' %}" class="btn-pill absolute top-3 left-3 no-underline">← Back</a>
|
||||
<!-- Edit -->
|
||||
<a href="{% url 'plant_edit' plant.pk %}" class="btn-pill absolute top-3 right-3 no-underline">Edit</a>
|
||||
<!-- Favorite (UI only) -->
|
||||
<button class="btn-pill absolute top-3 right-16">🤍</button>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<div class="px-4 pt-3 pb-4">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<div>
|
||||
<h6 class="mb-0">{{ plant.name }}</h6>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
{% 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 }}" target="_blank" rel="noopener" title="Search on Google">
|
||||
<img src="https://www.google.com/favicon.ico" width="14" height="14" style="opacity:0.7;vertical-align:middle;">
|
||||
<!-- Plant name + latin -->
|
||||
<h1 class="font-nunito font-black text-[22px] text-[#1A1208] mb-0.5">{{ plant.name }}</h1>
|
||||
{% if plant.species %}
|
||||
<div class="flex items-center gap-1.5 mb-3">
|
||||
<span class="font-nunito font-bold text-[13px] text-[#F07040] italic">{{ plant.species.scientific_name }}</span>
|
||||
<a href="https://www.google.com/search?q={{ plant.species.scientific_name|default:plant.name|urlencode }}"
|
||||
target="_blank" rel="noopener">
|
||||
<img src="https://www.google.com/favicon.ico" width="13" height="13" style="opacity:0.65;vertical-align:middle;">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge {% if plant.is_indoor %}bg-info{% else %}bg-success{% endif %}">
|
||||
{% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if plant.location %}<p class="text-muted small mb-2">📍 {{ plant.location.name }}</p>{% endif %}
|
||||
{% if plant.location %}
|
||||
<p class="font-nunito text-[11px] font-semibold text-[#B0A090] mb-3">
|
||||
📍 {{ plant.location.name }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if plant.watering or plant.sunlight or plant.species %}
|
||||
<div class="mb-3">
|
||||
<!-- Care row -->
|
||||
{% if plant.watering or plant.sunlight or plant.species %}
|
||||
<div class="flex justify-around py-3 border-t border-b border-[#F5EDE4] mb-4">
|
||||
{% if plant.watering %}
|
||||
<span class="care-chip" style="background:#e3f2fd; color:#1565c0;">{{ plant.get_watering_display }}</span>
|
||||
{% elif plant.species.watering %}
|
||||
<span class="care-chip" style="background:#e3f2fd; color:#1565c0;">💧 {{ plant.species.watering }}</span>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xl">💧</span>
|
||||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.get_watering_display }}</span>
|
||||
</div>
|
||||
{% elif plant.species and plant.species.watering %}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xl">💧</span>
|
||||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.watering }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plant.sunlight %}
|
||||
<span class="care-chip" style="background:#fff9c4; color:#7d4a00;">{{ plant.get_sunlight_display }}</span>
|
||||
{% elif plant.species.sunlight %}
|
||||
<span class="care-chip" style="background:#fff9c4; color:#7d4a00;">☀️ {{ plant.species.sunlight }}</span>
|
||||
{% endif %}
|
||||
{% if plant.species.max_height_cm %}
|
||||
<span class="care-chip" style="background:#f3e5f5; color:#4a148c;">📏 up to {{ plant.species.max_height_cm }} cm</span>
|
||||
{% endif %}
|
||||
{% if plant.species.frost_hardiness_c is not None %}
|
||||
<span class="care-chip" style="background:#e3f2fd; color:#0d47a1;">❄️ {{ plant.species.frost_hardiness_c }}°C</span>
|
||||
{% endif %}
|
||||
{% if plant.species.planting_density_m2 %}
|
||||
<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 %}
|
||||
</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 class="flex flex-col items-center gap-1">
|
||||
<span class="text-xl">☀️</span>
|
||||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.get_sunlight_display }}</span>
|
||||
</div>
|
||||
{% elif plant.species and plant.species.sunlight %}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xl">☀️</span>
|
||||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.sunlight }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plant.species and plant.species.frost_hardiness_c is not None %}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xl">🌡️</span>
|
||||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.frost_hardiness_c }}°C min</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plant.species and plant.species.max_height_cm %}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xl">📏</span>
|
||||
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.max_height_cm }} cm</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Bloom strip -->
|
||||
{% if plant.bloom_months %}
|
||||
<div class="mb-4 p-3 rounded-2xl bg-[#D8F3DC]">
|
||||
<p class="font-nunito font-bold text-[11px] text-[#1b4332] mb-1">🌸 Bloom months</p>
|
||||
{% month_strip plant.bloom_months %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Notes -->
|
||||
{% if plant.notes %}
|
||||
<div class="bg-white rounded-2xl px-4 py-3 border border-[#F0E4D4] mb-4">
|
||||
<p class="font-nunito text-[13px] text-[#6B7280]">{{ plant.notes|linebreaksbr }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pruning -->
|
||||
{% if plant.pruning_months %}
|
||||
{% include "plants/partials/pruning_strip.html" %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Card photos (crop thumbnails) -->
|
||||
{% if plant.card_photos.all %}
|
||||
<div class="mb-4">
|
||||
<p class="font-nunito font-bold text-[11px] text-[#B0A090] mb-2">🪧 Plant card photos</p>
|
||||
<a href="{% url 'crop_thumbnail' plant.pk %}" class="btn-bloom-outline no-underline">
|
||||
Edit card photo
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Photo gallery -->
|
||||
<div id="photo-gallery" class="mb-4">
|
||||
{% include "plants/partials/photo_gallery.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<a href="{% url 'plant_edit' plant.pk %}" class="btn-bloom no-underline">Edit this plant</a>
|
||||
<a href="{% url 'plant_card' plant.pk %}" class="btn-bloom-outline no-underline">🪧 Plant card</a>
|
||||
<a href="{% url 'plant_delete' plant.pk %}"
|
||||
class="font-nunito font-bold text-[13px] text-center text-[#DC2626] py-2 no-underline">
|
||||
Delete plant
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,58 @@
|
|||
{# plants/templates/plants/plant_list.html #}
|
||||
{% extends "plants/base.html" %}
|
||||
{% block title %}Plants — BloomBase{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-grid mb-3">
|
||||
<a href="{% url 'plant_add' %}" class="btn btn-success">+ Add a plant</a>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
|
||||
<div class="px-4 pt-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="font-nunito font-black text-[22px] text-[#1A1208]">All Plants</h1>
|
||||
<a href="{% url 'plant_add' %}"
|
||||
class="bb-fab w-10 h-10 flex items-center justify-center rounded-full no-underline text-xl font-light">+</a>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex items-center gap-2 bg-[#F5F0EA] rounded-full px-4 py-2.5 mb-3">
|
||||
<svg width="16" height="16" fill="none" stroke="#B0A090" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="{{ q }}"
|
||||
class="form-control"
|
||||
placeholder="🔍 Search plants..."
|
||||
placeholder="Search plants…"
|
||||
class="flex-1 bg-transparent font-nunito text-sm font-semibold text-[#1A1208]
|
||||
placeholder-[#C0B0A0] outline-none"
|
||||
hx-get="{% url 'plant_list' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#plant-list"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="btn-group flex-wrap flex-grow-1" role="group">
|
||||
<a href="{% url 'plant_list' %}" class="btn btn-sm {% if not location_filter %}btn-success{% else %}btn-outline-success{% endif %}">All</a>
|
||||
<!-- Location filter chips -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 mb-4 scrollbar-hide">
|
||||
<a href="{% url 'plant_list' %}"
|
||||
class="flex-shrink-0 px-4 py-1.5 rounded-full font-nunito font-bold text-[12px]
|
||||
{% if not location_filter %}bg-[#F07040] text-white{% else %}bg-white text-[#B0A090] border border-[#F0E4D4]{% endif %}
|
||||
no-underline">
|
||||
All
|
||||
</a>
|
||||
{% for loc in locations %}
|
||||
<a href="{% url 'plant_list' %}?location={{ loc.pk }}" class="btn btn-sm {% if location_filter == loc.pk|stringformat:'s' %}btn-success{% else %}btn-outline-success{% endif %}">{{ loc.name }}</a>
|
||||
<a href="{% url 'plant_list' %}?location={{ loc.pk }}"
|
||||
class="flex-shrink-0 px-4 py-1.5 rounded-full font-nunito font-bold text-[12px]
|
||||
{% if location_filter == loc.pk|stringformat:'s' %}bg-[#F07040] text-white{% else %}bg-white text-[#B0A090] border border-[#F0E4D4]{% endif %}
|
||||
no-underline">
|
||||
{{ loc.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="{% url 'location_list' %}" class="btn btn-sm btn-outline-secondary flex-shrink-0" title="Manage areas">⚙️</a>
|
||||
</div>
|
||||
|
||||
<div id="plant-list">
|
||||
<!-- Plant list -->
|
||||
<div id="plant-list">
|
||||
{% include "plants/partials/plant_list_results.html" %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
51
plants/tests/test_location_redesign.py
Normal file
51
plants/tests/test_location_redesign.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from plants.models import Location
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_location_cover_photo_field_exists():
|
||||
"""Location can be saved with and without a cover_photo."""
|
||||
loc = Location.objects.create(name='Test Garden')
|
||||
assert loc.cover_photo.name == '' or loc.cover_photo.name is None
|
||||
|
||||
fake_img = SimpleUploadedFile('cover.jpg', b'fake-image-data', content_type='image/jpeg')
|
||||
loc.cover_photo = fake_img
|
||||
loc.save()
|
||||
loc.refresh_from_db()
|
||||
assert loc.cover_photo.name.startswith('locations/')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return Client()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_location_detail_view_returns_200(client):
|
||||
loc = Location.objects.create(name='My Terrace')
|
||||
resp = client.get(reverse('location_detail', kwargs={'pk': loc.pk}))
|
||||
assert resp.status_code == 200
|
||||
assert b'My Terrace' in resp.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_location_edit_with_cover_photo(client):
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
loc = Location.objects.create(name='Balcony')
|
||||
buf = io.BytesIO()
|
||||
Image.new('RGB', (1, 1), color=(0, 255, 0)).save(buf, format='JPEG')
|
||||
buf.seek(0)
|
||||
fake_img = SimpleUploadedFile('cover.jpg', buf.read(), content_type='image/jpeg')
|
||||
resp = client.post(
|
||||
reverse('location_edit', kwargs={'pk': loc.pk}),
|
||||
{'name': 'Balcony Updated', 'cover_photo': fake_img},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
loc.refresh_from_db()
|
||||
assert loc.name == 'Balcony Updated'
|
||||
assert loc.cover_photo.name.startswith('locations/')
|
||||
|
|
@ -62,14 +62,6 @@ class TestPlantList:
|
|||
assert resp.status_code == 200
|
||||
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
|
||||
class TestPlantDetail:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ urlpatterns = [
|
|||
path('photos/<int:photo_pk>/delete/', plants.delete_photo, name='delete_photo'),
|
||||
path('pruning/', pruning.pruning_calendar, name='pruning_calendar'),
|
||||
path('locations/', locations.location_list, name='location_list'),
|
||||
path('locations/<int:pk>/', locations.location_detail, name='location_detail'),
|
||||
path('locations/<int:pk>/edit/', locations.location_edit, name='location_edit'),
|
||||
path('locations/<int:pk>/delete/', locations.location_delete, name='location_delete'),
|
||||
path('species/search/', species.species_search, name='species_search'),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# plants/views/dashboard.py
|
||||
from datetime import date
|
||||
from django.shortcuts import render
|
||||
from plants.models import Plant
|
||||
from plants.models import Plant, Location
|
||||
|
||||
|
||||
def dashboard(request):
|
||||
|
|
@ -8,6 +9,7 @@ def dashboard(request):
|
|||
all_plants = list(Plant.objects.select_related('species').all())
|
||||
blooming_now = [p for p in all_plants if today.month in (p.bloom_months or [])]
|
||||
latest_plants = Plant.objects.select_related('species', 'location').prefetch_related('photos').order_by('-pk')[:6]
|
||||
location_count = Location.objects.count()
|
||||
|
||||
return render(request, 'plants/dashboard.html', {
|
||||
'total_plants': len(all_plants),
|
||||
|
|
@ -15,4 +17,6 @@ def dashboard(request):
|
|||
'blooming_now_count': len(blooming_now),
|
||||
'today': today,
|
||||
'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.plantnet import PlantNetError
|
||||
from plants.services.vpc import search_species as vpc_search
|
||||
from plants.models import Plant, PlantPhoto, Species
|
||||
from plants.models import Plant, PlantPhoto, Species, Location
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -27,14 +27,14 @@ def _clear_session(request):
|
|||
|
||||
def identify_upload(request):
|
||||
if request.method != 'POST':
|
||||
return render(request, 'plants/identify_upload.html')
|
||||
return render(request, 'plants/identify_upload.html', {'active_tab': 'home'})
|
||||
|
||||
files = request.FILES.getlist('images')
|
||||
organs = request.POST.getlist('organs')
|
||||
|
||||
if not files:
|
||||
return render(request, 'plants/identify_upload.html',
|
||||
{'error': 'Please select at least one photo.'})
|
||||
{'error': 'Please select at least one photo.', 'active_tab': 'home'})
|
||||
|
||||
scan_id = uuid.uuid4().hex
|
||||
saved_paths = []
|
||||
|
|
@ -66,7 +66,7 @@ def identify_upload(request):
|
|||
default_storage.delete(p['path'])
|
||||
except Exception:
|
||||
pass
|
||||
return render(request, 'plants/identify_upload.html', {'error': error_msg})
|
||||
return render(request, 'plants/identify_upload.html', {'error': error_msg, 'active_tab': 'home'})
|
||||
|
||||
request.session['identify_matches'] = matches
|
||||
request.session['identify_image_paths'] = saved_paths
|
||||
|
|
@ -100,7 +100,11 @@ def identify_confirm(request):
|
|||
|
||||
return redirect('identify_fields')
|
||||
|
||||
return render(request, 'plants/identify_confirm.html', {'matches': matches})
|
||||
locations = list(Location.objects.all())
|
||||
return render(request, 'plants/identify_confirm.html', {
|
||||
'matches': matches,
|
||||
'locations': locations,
|
||||
})
|
||||
|
||||
|
||||
def _apply_vpc_care_fields(species, vpc_result):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from django.shortcuts import render, get_object_or_404, redirect
|
|||
from django.views.decorators.http import require_POST
|
||||
from django.db.models import Count
|
||||
from plants.models import Location, Plant
|
||||
from plants.forms import LocationForm
|
||||
|
||||
|
||||
def location_list(request):
|
||||
|
|
@ -12,18 +13,36 @@ def location_list(request):
|
|||
return redirect('location_list')
|
||||
|
||||
locations = Location.objects.annotate(plant_count=Count('plants'))
|
||||
return render(request, 'plants/locations.html', {'locations': locations})
|
||||
return render(request, 'plants/locations.html', {
|
||||
'locations': locations,
|
||||
'active_tab': 'garden',
|
||||
})
|
||||
|
||||
|
||||
def location_detail(request, pk):
|
||||
location = get_object_or_404(Location, pk=pk)
|
||||
plants = location.plants.select_related('species').prefetch_related('photos').all()
|
||||
return render(request, 'plants/location_detail.html', {
|
||||
'location': location,
|
||||
'plants': plants,
|
||||
'active_tab': 'garden',
|
||||
})
|
||||
|
||||
|
||||
def location_edit(request, pk):
|
||||
location = get_object_or_404(Location, pk=pk)
|
||||
if request.method == 'POST':
|
||||
name = request.POST.get('name', '').strip()
|
||||
if name:
|
||||
location.name = name
|
||||
location.save()
|
||||
form = LocationForm(request.POST, request.FILES, instance=location)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('location_list')
|
||||
return render(request, 'plants/location_edit.html', {'location': location})
|
||||
else:
|
||||
form = LocationForm(instance=location)
|
||||
return render(request, 'plants/location_edit.html', {
|
||||
'location': location,
|
||||
'form': form,
|
||||
'active_tab': 'garden',
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ def plant_list(request):
|
|||
'plants': qs, 'q': q,
|
||||
'location_filter': location_filter,
|
||||
'locations': locations,
|
||||
'active_tab': 'garden',
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ def plant_detail(request, pk):
|
|||
'pruning_status': pruning_status(plant, today),
|
||||
'log_form': PruningLogForm(initial={'pruned_on': today}),
|
||||
'today': today,
|
||||
'active_tab': 'garden',
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = plantdb.settings
|
||||
DJANGO_SETTINGS_MODULE = bloombase.settings
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue