diff --git a/Planteer/Planteer/__init__.py b/Planteer/Planteer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/Planteer/asgi.py b/Planteer/Planteer/asgi.py new file mode 100644 index 0000000..7693f2d --- /dev/null +++ b/Planteer/Planteer/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for Planteer project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Planteer.settings') + +application = get_asgi_application() diff --git a/Planteer/Planteer/settings.py b/Planteer/Planteer/settings.py new file mode 100644 index 0000000..ee92ba1 --- /dev/null +++ b/Planteer/Planteer/settings.py @@ -0,0 +1,127 @@ +""" +Django settings for Planteer project. + +Generated by 'django-admin startproject' using Django 6.0.4. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-8xc6_a1f_o+5kui$8s1v2$kx8c!#@ljv#i_*5dd&a@6_j#+ugs' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'main', + 'plants', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'Planteer.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'Planteer.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Media files — uploaded images (ImageField) +# NOTE: pip install Pillow is required for ImageField support +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' diff --git a/Planteer/Planteer/urls.py b/Planteer/Planteer/urls.py new file mode 100644 index 0000000..1b5f9ba --- /dev/null +++ b/Planteer/Planteer/urls.py @@ -0,0 +1,17 @@ +""" +URL configuration for Planteer project. +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('main.urls')), + path('plants/', include('plants.urls')), +] + +# Serve media files during development only +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/Planteer/Planteer/wsgi.py b/Planteer/Planteer/wsgi.py new file mode 100644 index 0000000..f14ac6f --- /dev/null +++ b/Planteer/Planteer/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for Planteer project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Planteer.settings') + +application = get_wsgi_application() diff --git a/Planteer/db.sqlite3 b/Planteer/db.sqlite3 new file mode 100644 index 0000000..43813b7 Binary files /dev/null and b/Planteer/db.sqlite3 differ diff --git a/Planteer/main/__init__.py b/Planteer/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/main/admin.py b/Planteer/main/admin.py new file mode 100644 index 0000000..dd3b3e7 --- /dev/null +++ b/Planteer/main/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from .models import Contact + + +@admin.register(Contact) +class ContactAdmin(admin.ModelAdmin): + list_display = ('first_name', 'last_name', 'email', 'created_at') + list_filter = ('created_at',) + search_fields = ('first_name', 'last_name', 'email', 'message') diff --git a/Planteer/main/apps.py b/Planteer/main/apps.py new file mode 100644 index 0000000..833bff6 --- /dev/null +++ b/Planteer/main/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MainConfig(AppConfig): + name = 'main' diff --git a/Planteer/main/forms.py b/Planteer/main/forms.py new file mode 100644 index 0000000..ebd5c20 --- /dev/null +++ b/Planteer/main/forms.py @@ -0,0 +1,36 @@ +from django import forms +from .models import Contact + + +class ContactForm(forms.ModelForm): + class Meta: + model = Contact + fields = ['first_name', 'last_name', 'email', 'message'] + widgets = { + 'first_name': forms.TextInput(attrs={ + 'placeholder': 'First name', + 'required': True, + 'minlength': '2', + }), + 'last_name': forms.TextInput(attrs={ + 'placeholder': 'Last name', + 'required': True, + 'minlength': '2', + }), + 'email': forms.EmailInput(attrs={ + 'placeholder': 'your@email.com', + 'required': True, + }), + 'message': forms.Textarea(attrs={ + 'placeholder': 'Your message...', + 'required': True, + 'rows': 5, + 'minlength': '10', + }), + } + + def clean_message(self): + message = self.cleaned_data.get('message', '').strip() + if len(message) < 10: + raise forms.ValidationError("Message must be at least 10 characters.") + return message diff --git a/Planteer/main/migrations/0001_initial.py b/Planteer/main/migrations/0001_initial.py new file mode 100644 index 0000000..e412e30 --- /dev/null +++ b/Planteer/main/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.4 on 2026-04-18 22:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(max_length=100)), + ('email', models.EmailField(max_length=254)), + ('message', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/Planteer/main/migrations/__init__.py b/Planteer/main/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/main/models.py b/Planteer/main/models.py new file mode 100644 index 0000000..2197cfb --- /dev/null +++ b/Planteer/main/models.py @@ -0,0 +1,15 @@ +from django.db import models + + +class Contact(models.Model): + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) + email = models.EmailField() + message = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.first_name} {self.last_name} — {self.email}" + + class Meta: + ordering = ['-created_at'] diff --git a/Planteer/main/static/main/css/styles.css b/Planteer/main/static/main/css/styles.css new file mode 100644 index 0000000..2ea4b6f --- /dev/null +++ b/Planteer/main/static/main/css/styles.css @@ -0,0 +1,1058 @@ +/* ============================================================ + Planteer — Custom Stylesheet + Plain CSS, no frameworks. Minimalist flat design. + Font: Inter from Google Fonts + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +/* ── Reset & Base ───────────────────────────────────────── */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} + +body { + font-family: 'Inter', sans-serif; + background-color: #ffffff; + color: #14532d; + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +a { + color: inherit; + text-decoration: none; +} + +ul { list-style: none; } + +img { + display: block; + max-width: 100%; + height: auto; +} + +/* ── Container ──────────────────────────────────────────── */ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +/* ── Typography ─────────────────────────────────────────── */ +h1 { font-size: 2.75rem; font-weight: 700; line-height: 1.15; } +h2 { font-size: 2rem; font-weight: 700; line-height: 1.2; } +h3 { font-size: 1.25rem; font-weight: 600; line-height: 1.3; } +h4 { font-size: 1rem; font-weight: 600; } + +.text-gray { color: #6b7280; } +.text-small { font-size: 0.85rem; color: #6b7280; } +.text-email a { color: #2563eb; } + +/* ── Buttons ────────────────────────────────────────────── */ +.btn { + display: inline-block; + padding: 10px 22px; + background: #14532d; + color: #ffffff; + border: none; + border-radius: 6px; + font-family: inherit; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.18s ease; + white-space: nowrap; +} + +.btn:hover { opacity: 0.82; } + +.btn-outline { + background: transparent; + color: #14532d; + border: 1.5px solid #14532d; +} + +.btn-outline:hover { + background: #14532d; + color: #ffffff; + opacity: 1; +} + +.btn-danger { + background: #dc2626; +} + +.btn-sm { + padding: 7px 16px; + font-size: 0.875rem; +} + +/* ── Form elements ──────────────────────────────────────── */ +.form-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 18px; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-family: inherit; + font-size: 0.95rem; + color: #14532d; + background: #ffffff; + transition: border-color 0.15s ease; + outline: none; +} + +.form-group input::placeholder, +.form-group textarea::placeholder { + color: #9ca3af; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + border-color: #6b7280; +} + +.form-group .errorlist { + margin: 0; + padding: 0; + list-style: none; +} + +.form-group .errorlist li { + font-size: 0.8rem; + color: #dc2626; + margin-top: 2px; +} + +/* checkbox row */ +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 18px; +} + +.checkbox-row input[type="checkbox"] { + width: 17px; + height: 17px; + cursor: pointer; + accent-color: #14532d; +} + +.checkbox-row label { + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; +} + +/* ── Messages (Django flash messages) ───────────────────── */ +.messages-container { + width: 100%; + padding: 0 24px; +} + +.messages-container .message { + max-width: 1200px; + margin: 12px auto 0; + padding: 12px 18px; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 500; +} + +.message.success { + background: #f0fdf4; + color: #166534; + border: 1px solid #bbf7d0; +} + +.message.error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} + +.message.warning { + background: #fffbeb; + color: #92400e; + border: 1px solid #fde68a; +} + +.message.info { + background: #eff6ff; + color: #1e40af; + border: 1px solid #bfdbfe; +} + +/* ── Navbar ─────────────────────────────────────────────── */ +.navbar { + border-bottom: 1px solid #e5e7eb; + padding: 0 24px; + position: sticky; + top: 0; + background: #ffffff; + z-index: 100; +} + +.navbar-inner { + max-width: 1200px; + margin: 0 auto; + height: 64px; + display: flex; + align-items: center; + gap: 32px; +} + +.navbar-logo { + font-size: 1.25rem; + font-weight: 700; + color: #14532d; + letter-spacing: -0.5px; + flex-shrink: 0; +} + +.navbar-links { + display: flex; + align-items: center; + gap: 28px; + margin-left: auto; +} + +.navbar-links a { + font-size: 0.95rem; + font-weight: 500; + color: #6b7280; + transition: color 0.15s; +} + +.navbar-links a:hover { color: #14532d; } + +.navbar-links .btn { + font-size: 0.875rem; + padding: 8px 18px; +} + +/* Mobile hamburger (hidden by default) */ +.nav-toggle { + display: none; + flex-direction: column; + gap: 5px; + cursor: pointer; + margin-left: auto; + background: none; + border: none; + padding: 4px; +} + +.nav-toggle span { + display: block; + width: 24px; + height: 2px; + background: #14532d; + border-radius: 2px; + transition: transform 0.2s; +} + +/* ── Footer ─────────────────────────────────────────────── */ +.footer { + margin-top: auto; + border-top: 1px solid #e5e7eb; + padding: 48px 24px; +} + +.footer-inner { + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr 3fr; + gap: 48px; +} + +.footer-brand .brand-name { + font-size: 1.1rem; + font-weight: 700; +} + +.footer-brand .social-icons { + display: flex; + gap: 14px; + margin-top: 14px; +} + +.social-icons a { + width: 32px; + height: 32px; + border: 1px solid #e5e7eb; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + color: #6b7280; + transition: border-color 0.15s, color 0.15s; +} + +.social-icons a:hover { + border-color: #14532d; + color: #14532d; +} + +.footer-links { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.footer-col h4 { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 14px; + color: #14532d; +} + +.footer-col ul li { + margin-bottom: 8px; +} + +.footer-col ul li a { + font-size: 0.875rem; + color: #6b7280; + transition: color 0.15s; +} + +.footer-col ul li a:hover { color: #14532d; } + +/* ── Plant Cards ────────────────────────────────────────── */ +.card-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 32px; +} + +.plant-card { + display: flex; + flex-direction: column; + cursor: pointer; +} + +.plant-card a { + display: flex; + flex-direction: column; + height: 100%; +} + +.plant-card .card-img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + border-radius: 6px; + background: #f3f4f6; +} + +.plant-card .card-img-placeholder { + width: 100%; + aspect-ratio: 4 / 3; + background: #f3f4f6; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: #9ca3af; + font-size: 0.85rem; +} + +.plant-card .card-body { + padding: 14px 0 0; +} + +.plant-card .card-name { + font-size: 1rem; + font-weight: 600; + color: #14532d; + margin-bottom: 4px; +} + +.plant-card .card-about { + font-size: 0.875rem; + color: #6b7280; + margin-bottom: 6px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.plant-card .card-category { + font-size: 0.78rem; + color: #9ca3af; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* ── Home Page ──────────────────────────────────────────── */ +.hero { + padding: 80px 24px 60px; + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 48px; + align-items: center; +} + +.hero-text h1 { + font-size: 3rem; + font-weight: 700; + line-height: 1.1; + margin-bottom: 32px; + color: #14532d; +} + +.hero-search { + display: flex; + gap: 10px; +} + +.hero-search input { + flex: 1; + padding: 11px 16px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-family: inherit; + font-size: 0.95rem; + color: #14532d; + outline: none; + transition: border-color 0.15s; +} + +.hero-search input:focus { border-color: #6b7280; } +.hero-search input::placeholder { color: #9ca3af; } + +.hero-img img { + width: 100%; + max-height: 420px; + object-fit: cover; + border-radius: 8px; +} + +.hero-img .hero-img-placeholder { + width: 100%; + height: 380px; + background: #f3f4f6; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: #9ca3af; + font-size: 1rem; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.section-header h2 { font-size: 1.5rem; } + +.section-header .more-link { + font-size: 0.9rem; + color: #6b7280; + font-weight: 500; + transition: color 0.15s; +} + +.section-header .more-link:hover { color: #14532d; } + +.section-subheading { + font-size: 0.9rem; + color: #6b7280; + margin-bottom: 32px; +} + +.plants-section { + padding: 48px 24px 80px; + max-width: 1200px; + margin: 0 auto; +} + +/* ── All Plants Page ────────────────────────────────────── */ +.page-header { + padding: 52px 24px 32px; + max-width: 1200px; + margin: 0 auto; +} + +.page-header h1 { font-size: 2.25rem; margin-bottom: 6px; } + +.filter-bar { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + margin-bottom: 40px; +} + +.filter-bar select, +.filter-bar input[type="text"] { + padding: 9px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-family: inherit; + font-size: 0.9rem; + color: #14532d; + background: #ffffff; + outline: none; + transition: border-color 0.15s; +} + +.filter-bar select:focus, +.filter-bar input[type="text"]:focus { + border-color: #6b7280; +} + +.filter-bar .checkbox-inline { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; +} + +.filter-bar .checkbox-inline input[type="checkbox"] { + accent-color: #14532d; + width: 16px; + height: 16px; +} + +.all-plants-grid { + padding: 0 24px 80px; + max-width: 1200px; + margin: 0 auto; +} + +.empty-state { + padding: 64px 0; + text-align: center; + color: #6b7280; +} + +.empty-state p { font-size: 1rem; } + +/* ── Plant Detail Page ──────────────────────────────────── */ +.detail-section { + padding: 52px 24px 60px; + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 56px; + align-items: start; +} + +.detail-img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + border-radius: 8px; + background: #f3f4f6; +} + +.detail-img-placeholder { + width: 100%; + aspect-ratio: 4 / 3; + background: #f3f4f6; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: #9ca3af; +} + +.detail-info h1 { + font-size: 2rem; + margin-bottom: 6px; +} + +.detail-category { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9ca3af; + margin-bottom: 24px; +} + +.detail-info h3 { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9ca3af; + font-weight: 600; + margin-bottom: 6px; + margin-top: 22px; +} + +.detail-info p { + font-size: 0.95rem; + color: #374151; + line-height: 1.65; +} + +.edible-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 600; +} + +.edible-yes { background: #f0fdf4; color: #166534; } +.edible-no { background: #fef2f2; color: #991b1b; } + +.detail-actions { + display: flex; + gap: 10px; + margin-top: 28px; +} + +.related-section { + padding: 0 24px 80px; + max-width: 1200px; + margin: 0 auto; +} + +.related-section h2 { + font-size: 1.5rem; + margin-bottom: 32px; +} + +.divider { + border: none; + border-top: 1px solid #e5e7eb; + margin: 0 24px 48px; + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +/* ── Form Pages (new / update plant) ────────────────────── */ +.form-page { + padding: 52px 24px 80px; + max-width: 680px; + margin: 0 auto; +} + +.form-page h1 { + font-size: 2rem; + margin-bottom: 8px; +} + +.form-page .form-subtitle { + font-size: 0.9rem; + color: #6b7280; + margin-bottom: 36px; +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 8px; +} + +.current-image-preview { + margin-bottom: 6px; +} + +.current-image-preview img { + width: 120px; + height: 90px; + object-fit: cover; + border-radius: 6px; + border: 1px solid #e5e7eb; +} + +.current-image-preview p { + font-size: 0.78rem; + color: #9ca3af; + margin-top: 4px; +} + +/* ── Search Page ────────────────────────────────────────── */ +.search-page { + padding: 52px 24px 80px; + max-width: 1200px; + margin: 0 auto; +} + +.search-page h1 { + font-size: 2rem; + margin-bottom: 28px; +} + +.search-bar { + display: flex; + gap: 10px; + margin-bottom: 44px; + max-width: 560px; +} + +.search-bar input { + flex: 1; + padding: 11px 16px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-family: inherit; + font-size: 0.95rem; + outline: none; + transition: border-color 0.15s; +} + +.search-bar input:focus { border-color: #6b7280; } +.search-bar input::placeholder { color: #9ca3af; } + +.search-results-count { + font-size: 0.9rem; + color: #6b7280; + margin-bottom: 28px; +} + +/* ── Contact Page ───────────────────────────────────────── */ +.contact-section { + padding: 52px 24px 80px; + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 80px; + align-items: start; +} + +.contact-left h1 { + font-size: 2.25rem; + margin-bottom: 10px; +} + +.contact-subheading { + font-size: 0.95rem; + color: #6b7280; + margin-bottom: 36px; +} + +.contact-right img { + width: 100%; + height: 520px; + object-fit: cover; + border-radius: 8px; +} + +.contact-right .img-placeholder { + width: 100%; + height: 520px; + background: #f3f4f6; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: #9ca3af; +} + +/* ── Messages Page ──────────────────────────────────────── */ +.messages-page { + padding: 52px 24px 80px; + max-width: 1200px; + margin: 0 auto; +} + +.messages-page h1 { + font-size: 2rem; + margin-bottom: 40px; +} + +.msg-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24px; +} + +.msg-card { + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 22px 24px; +} + +.msg-card-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 14px; +} + +.user-icon { + width: 40px; + height: 40px; + background: #f3f4f6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + flex-shrink: 0; +} + +.msg-card-meta .full-name { + font-size: 0.95rem; + font-weight: 600; +} + +.msg-card-meta .email-link a { + font-size: 0.85rem; + color: #2563eb; +} + +.msg-card-body { + font-size: 0.9rem; + color: #374151; + line-height: 1.6; +} + +.msg-empty { + padding: 64px 0; + text-align: center; + color: #6b7280; +} + +/* ── Responsive — Tablet (≤ 900px) ─────────────────────── */ +@media (max-width: 900px) { + h1 { font-size: 2.25rem; } + + .hero { + grid-template-columns: 1fr; + padding: 56px 24px 40px; + gap: 36px; + } + + .hero-text h1 { font-size: 2.25rem; } + + .detail-section { + grid-template-columns: 1fr; + gap: 32px; + } + + .contact-section { + grid-template-columns: 1fr; + gap: 40px; + } + + .contact-right { display: none; } + + .footer-inner { + grid-template-columns: 1fr; + gap: 32px; + } + + .footer-links { + grid-template-columns: repeat(2, 1fr); + } +} + +/* ── Responsive — Mobile (≤ 640px) ─────────────────────── */ +@media (max-width: 640px) { + h1 { font-size: 1.875rem; } + + /* Navbar */ + .navbar-links { + display: none; + flex-direction: column; + align-items: flex-start; + gap: 16px; + position: absolute; + top: 64px; + left: 0; + right: 0; + background: #ffffff; + border-bottom: 1px solid #e5e7eb; + padding: 20px 24px; + z-index: 99; + } + + .navbar-links.open { display: flex; } + + .navbar { position: relative; } + + .nav-toggle { display: flex; } + + /* Cards: single column */ + .card-grid { + grid-template-columns: 1fr; + gap: 24px; + } + + .msg-grid { + grid-template-columns: 1fr; + } + + .filter-bar { + flex-direction: column; + align-items: flex-start; + } + + .filter-bar select { width: 100%; } + + .footer-links { + grid-template-columns: 1fr; + } + + .hero-search { + flex-direction: column; + } + + .hero-search .btn { width: 100%; text-align: center; } + + .search-bar { + flex-direction: column; + max-width: 100%; + } + + .form-page { padding: 36px 20px 60px; } + + .detail-actions { flex-wrap: wrap; } + + .messages-page h1, + .search-page h1, + .form-page h1 { font-size: 1.75rem; } +} + +/* ── Responsive — Tablet cards (640px–900px) ────────────── */ +@media (min-width: 641px) and (max-width: 900px) { + .card-grid { + grid-template-columns: repeat(2, 1fr); + gap: 24px; + } +} + +/* ── Comments ───────────────────────────────────────────── */ +.comments-section { + max-width: 800px; + margin: 40px auto; + padding: 0 24px 60px; +} + +.comments-section h2 { + font-size: 1.4rem; + font-weight: 600; + margin-bottom: 24px; + color: #14532d; +} + +.comment-card { + border: 1px solid #d1fae5; + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 16px; + background: #f0fdf4; +} + +.comment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.comment-author { + font-weight: 600; + color: #166534; +} + +.comment-date { + font-size: 0.8rem; + color: #6b7280; +} + +.comment-content { + color: #374151; + line-height: 1.6; +} + +.no-comments { + color: #6b7280; + font-style: italic; + margin-bottom: 24px; +} + +.comment-form-wrapper { + margin-top: 32px; + padding: 24px; + border: 1px solid #d1fae5; + border-radius: 8px; + background: #ffffff; +} + +.comment-form-wrapper h3 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 20px; + color: #14532d; +} + +.comment-form-wrapper .form-group { + margin-bottom: 16px; +} + +.comment-form-wrapper label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 6px; + color: #374151; +} + +.comment-form-wrapper input, +.comment-form-wrapper textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-family: inherit; + font-size: 0.9rem; + color: #111827; + outline: none; + transition: border-color 0.2s; +} + +.comment-form-wrapper input:focus, +.comment-form-wrapper textarea:focus { + border-color: #16a34a; +} + +.field-error { + color: #dc2626; + font-size: 0.8rem; + margin-top: 4px; +} diff --git a/Planteer/main/templates/main/base.html b/Planteer/main/templates/main/base.html new file mode 100644 index 0000000..6e26d6b --- /dev/null +++ b/Planteer/main/templates/main/base.html @@ -0,0 +1,100 @@ +{% load static %} + + + + + + {% block title %}Planteer{% endblock %} + + + + + + + + + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + + +
+ {% block content %}{% endblock %} +
+ + + + + + + + + diff --git a/Planteer/main/templates/main/contact.html b/Planteer/main/templates/main/contact.html new file mode 100644 index 0000000..975018e --- /dev/null +++ b/Planteer/main/templates/main/contact.html @@ -0,0 +1,51 @@ +{% extends 'main/base.html' %} + +{% block title %}Contact Us — Planteer{% endblock %} + +{% block content %} +
+ + +
+

Contact us

+

Have a question or suggestion? We’d love to hear from you.

+ +
+ {% csrf_token %} + +
+
+ + {{ form.first_name }} + {{ form.first_name.errors }} +
+
+ + {{ form.last_name }} + {{ form.last_name.errors }} +
+
+ +
+ + {{ form.email }} + {{ form.email.errors }} +
+ +
+ + {{ form.message }} + {{ form.message.errors }} +
+ + +
+
+ + +
+
🌿
+
+ +
+{% endblock %} diff --git a/Planteer/main/templates/main/home.html b/Planteer/main/templates/main/home.html new file mode 100644 index 0000000..71f0c5c --- /dev/null +++ b/Planteer/main/templates/main/home.html @@ -0,0 +1,66 @@ +{% extends 'main/base.html' %} + +{% block title %}Planteer — Plant Database For Plant Lovers{% endblock %} + +{% block content %} + + +
+
+
+

Planteer — Plant Database For Plants Lovers

+ +
+
+ {% if latest_plants and latest_plants.0.image %} + Featured plant + {% else %} +
🌿 Planteer
+ {% endif %} +
+
+
+ + +
+
+

Plants

+ More → +
+

Learn more about plants

+ + {% if latest_plants %} +
+ {% for plant in latest_plants %} + + {% endfor %} +
+ {% else %} +
+

No plants yet. Add the first one!

+
+ {% endif %} +
+ +{% endblock %} diff --git a/Planteer/main/templates/main/messages.html b/Planteer/main/templates/main/messages.html new file mode 100644 index 0000000..b3af53b --- /dev/null +++ b/Planteer/main/templates/main/messages.html @@ -0,0 +1,32 @@ +{% extends 'main/base.html' %} + +{% block title %}Messages from Users — Planteer{% endblock %} + +{% block content %} +
+

Messages from Users

+ + {% if contact_messages %} +
+ {% for msg in contact_messages %} +
+
+
👤
+
+

{{ msg.first_name }} {{ msg.last_name }}

+ +
+
+
+

{{ msg.message }}

+
+
+ {% endfor %} +
+ {% else %} +
+

No messages yet. Be the first to reach out!

+
+ {% endif %} +
+{% endblock %} diff --git a/Planteer/main/tests.py b/Planteer/main/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Planteer/main/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Planteer/main/urls.py b/Planteer/main/urls.py new file mode 100644 index 0000000..e4de275 --- /dev/null +++ b/Planteer/main/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.home, name='home'), + path('contact/', views.contact, name='contact'), + path('contact/messages/', views.messages_list, name='messages_list'), +] diff --git a/Planteer/main/views.py b/Planteer/main/views.py new file mode 100644 index 0000000..6033937 --- /dev/null +++ b/Planteer/main/views.py @@ -0,0 +1,29 @@ +from django.shortcuts import render, redirect +from django.contrib import messages +from .forms import ContactForm +from .models import Contact +from plants.models import Plant + + +def home(request): + latest_plants = Plant.objects.all()[:3] + return render(request, 'main/home.html', {'latest_plants': latest_plants}) + + +def contact(request): + if request.method == 'POST': + form = ContactForm(request.POST) + if form.is_valid(): + form.save() + messages.success(request, 'Your message has been sent successfully!') + return redirect('contact') + else: + messages.error(request, 'Please correct the errors below.') + else: + form = ContactForm() + return render(request, 'main/contact.html', {'form': form}) + + +def messages_list(request): + contact_messages = Contact.objects.all() + return render(request, 'main/messages.html', {'contact_messages': contact_messages}) diff --git a/Planteer/manage.py b/Planteer/manage.py new file mode 100644 index 0000000..bd0a64a --- /dev/null +++ b/Planteer/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Planteer.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/Planteer/media/plants/aloevera.jpg b/Planteer/media/plants/aloevera.jpg new file mode 100644 index 0000000..e17ff53 Binary files /dev/null and b/Planteer/media/plants/aloevera.jpg differ diff --git a/Planteer/media/plants/bamboo.jpg b/Planteer/media/plants/bamboo.jpg new file mode 100644 index 0000000..6aab2d3 Binary files /dev/null and b/Planteer/media/plants/bamboo.jpg differ diff --git a/Planteer/media/plants/basil.jpg b/Planteer/media/plants/basil.jpg new file mode 100644 index 0000000..78f7a98 Binary files /dev/null and b/Planteer/media/plants/basil.jpg differ diff --git a/Planteer/media/plants/cactus.jpg b/Planteer/media/plants/cactus.jpg new file mode 100644 index 0000000..518b01f Binary files /dev/null and b/Planteer/media/plants/cactus.jpg differ diff --git a/Planteer/media/plants/garlic.jpg b/Planteer/media/plants/garlic.jpg new file mode 100644 index 0000000..ebbc44e Binary files /dev/null and b/Planteer/media/plants/garlic.jpg differ diff --git a/Planteer/media/plants/lavender.jpg b/Planteer/media/plants/lavender.jpg new file mode 100644 index 0000000..b829b3f Binary files /dev/null and b/Planteer/media/plants/lavender.jpg differ diff --git a/Planteer/media/plants/mango.jpg b/Planteer/media/plants/mango.jpg new file mode 100644 index 0000000..dd2c35e Binary files /dev/null and b/Planteer/media/plants/mango.jpg differ diff --git a/Planteer/media/plants/mint.jpg b/Planteer/media/plants/mint.jpg new file mode 100644 index 0000000..dafeca7 Binary files /dev/null and b/Planteer/media/plants/mint.jpg differ diff --git a/Planteer/media/plants/olive_tree.jpg b/Planteer/media/plants/olive_tree.jpg new file mode 100644 index 0000000..5da4eeb Binary files /dev/null and b/Planteer/media/plants/olive_tree.jpg differ diff --git a/Planteer/media/plants/palm_date.jpg b/Planteer/media/plants/palm_date.jpg new file mode 100644 index 0000000..5fa8196 Binary files /dev/null and b/Planteer/media/plants/palm_date.jpg differ diff --git a/Planteer/media/plants/rosemary.jpg b/Planteer/media/plants/rosemary.jpg new file mode 100644 index 0000000..195be66 Binary files /dev/null and b/Planteer/media/plants/rosemary.jpg differ diff --git a/Planteer/media/plants/roses.jpg b/Planteer/media/plants/roses.jpg new file mode 100644 index 0000000..9fbafd5 Binary files /dev/null and b/Planteer/media/plants/roses.jpg differ diff --git a/Planteer/media/plants/strawberry.jpg b/Planteer/media/plants/strawberry.jpg new file mode 100644 index 0000000..397985a Binary files /dev/null and b/Planteer/media/plants/strawberry.jpg differ diff --git a/Planteer/media/plants/sunflower.jpg b/Planteer/media/plants/sunflower.jpg new file mode 100644 index 0000000..6e6b6d8 Binary files /dev/null and b/Planteer/media/plants/sunflower.jpg differ diff --git a/Planteer/media/plants/tomato.jpg b/Planteer/media/plants/tomato.jpg new file mode 100644 index 0000000..10f8cf1 Binary files /dev/null and b/Planteer/media/plants/tomato.jpg differ diff --git a/Planteer/media/plants/tulip.jpg b/Planteer/media/plants/tulip.jpg new file mode 100644 index 0000000..74c3ceb Binary files /dev/null and b/Planteer/media/plants/tulip.jpg differ diff --git a/Planteer/media/plants/watermelon.jpg b/Planteer/media/plants/watermelon.jpg new file mode 100644 index 0000000..6cdef52 Binary files /dev/null and b/Planteer/media/plants/watermelon.jpg differ diff --git a/Planteer/plants/__init__.py b/Planteer/plants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/plants/admin.py b/Planteer/plants/admin.py new file mode 100644 index 0000000..896623d --- /dev/null +++ b/Planteer/plants/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from .models import Plant, Comment + + +@admin.register(Plant) +class PlantAdmin(admin.ModelAdmin): + list_display = ('name', 'category', 'is_edible', 'created_at') + list_filter = ('category', 'is_edible') + search_fields = ('name', 'about', 'used_for') + + +@admin.register(Comment) +class CommentAdmin(admin.ModelAdmin): + list_display = ('name', 'plant', 'created_at') + list_filter = ('plant',) diff --git a/Planteer/plants/apps.py b/Planteer/plants/apps.py new file mode 100644 index 0000000..478c3ce --- /dev/null +++ b/Planteer/plants/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PlantsConfig(AppConfig): + name = 'plants' diff --git a/Planteer/plants/forms.py b/Planteer/plants/forms.py new file mode 100644 index 0000000..3ab1769 --- /dev/null +++ b/Planteer/plants/forms.py @@ -0,0 +1,49 @@ +from django import forms +from .models import Plant, Comment + + +class PlantForm(forms.ModelForm): + class Meta: + model = Plant + fields = ['name', 'about', 'used_for', 'image', 'category', 'is_edible'] + widgets = { + 'name': forms.TextInput(attrs={ + 'placeholder': 'Plant name', + 'required': True, + 'minlength': '2', + }), + 'about': forms.Textarea(attrs={ + 'placeholder': 'Describe the plant...', + 'required': True, + 'rows': 4, + }), + 'used_for': forms.Textarea(attrs={ + 'placeholder': 'What is this plant used for?', + 'required': True, + 'rows': 3, + }), + 'category': forms.Select(attrs={ + 'required': True, + }), + } + + def clean_name(self): + name = self.cleaned_data.get('name', '').strip() + if len(name) < 2: + raise forms.ValidationError("Plant name must be at least 2 characters.") + return name + + +class CommentForm(forms.ModelForm): + class Meta: + model = Comment + fields = ['name', 'content'] + widgets = { + 'name': forms.TextInput(attrs={ + 'placeholder': 'Your name', + }), + 'content': forms.Textarea(attrs={ + 'placeholder': 'Leave your feedback...', + 'rows': 3, + }), + } diff --git a/Planteer/plants/management/__init__.py b/Planteer/plants/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/plants/management/commands/__init__.py b/Planteer/plants/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/plants/management/commands/seed_plants.py b/Planteer/plants/management/commands/seed_plants.py new file mode 100644 index 0000000..c250841 --- /dev/null +++ b/Planteer/plants/management/commands/seed_plants.py @@ -0,0 +1,362 @@ +""" +Management command to seed the database with sample plant data. + +Usage: + python manage.py seed_plants + +Clears all existing plants and inserts ~10 fresh samples with images +downloaded from Unsplash (free, no attribution required for seed use). + +NOTE: pip install Pillow is required for ImageField support. +NOTE: An internet connection is needed on first run to fetch images. +""" +import os +import urllib.request +from django.core.management.base import BaseCommand +from django.core.files.base import ContentFile +from plants.models import Plant + + +PLANTS_DATA = [ + { + 'name': 'Olive Tree', + 'about': ( + 'The olive tree (Olea europaea) is one of the oldest cultivated trees in the world, ' + 'native to the Mediterranean basin. It is an evergreen tree known for its gnarled trunk, ' + 'silver-green leaves, and small, oval fruits. Olive trees can live for thousands of years ' + 'and are a symbol of peace, wisdom, and longevity.' + ), + 'used_for': ( + 'Olive oil production, table olives, traditional medicine, woodworking, ' + 'and as an ornamental landscape tree.' + ), + 'category': Plant.Category.TREE, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1598512752271-33f913a5af13?w=800&q=80', + 'image_filename': 'olive_tree.jpg', + }, + { + 'name': 'Date Palm', + 'about': ( + 'The date palm (Phoenix dactylifera) is a flowering plant species in the palm family ' + 'cultivated for its sweet, edible fruits. It thrives in hot, arid climates and has been ' + 'a staple food source in the Middle East and North Africa for thousands of years. ' + 'It can grow up to 30 metres tall.' + ), + 'used_for': ( + 'Fruit production (dates), sugar extraction, palm frond weaving, timber, ' + 'and traditional medicine.' + ), + 'category': Plant.Category.TREE, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1509316785289-025f5b846b35?w=800&q=80', + 'image_filename': 'date_palm.jpg', + }, + { + 'name': 'Rose', + 'about': ( + 'The rose (Rosa) is a woody perennial flowering plant of the genus Rosa in the family ' + 'Rosaceae. Roses are renowned for their beauty and fragrance. There are over 300 species ' + 'and thousands of cultivars. They grow as shrubs, climbers, and trailing plants, with ' + 'blooms in nearly every colour.' + ), + 'used_for': ( + 'Ornamental gardening, perfume and essential oil extraction (rose water, attar of roses), ' + 'culinary decoration, and gift-giving.' + ), + 'category': Plant.Category.FLOWER, + 'is_edible': False, + 'image_url': 'https://images.unsplash.com/photo-1518895949257-7621c3c786d7?w=800&q=80', + 'image_filename': 'rose.jpg', + }, + { + 'name': 'Mint', + 'about': ( + 'Mint (Mentha) is a genus of plants in the family Lamiaceae. It is an aromatic herb ' + 'known for its refreshing, cool scent and flavour. Mint spreads rapidly via underground ' + 'runners and is found in moist habitats worldwide. Common varieties include spearmint ' + 'and peppermint.' + ), + 'used_for': ( + 'Culinary seasoning (teas, salads, sauces), digestive medicine, oral hygiene products, ' + 'aromatherapy, and insect repellent.' + ), + 'category': Plant.Category.HERB, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1471193945509-9ad0617afabf?w=800&q=80', + 'image_filename': 'mint.jpg', + }, + { + 'name': 'Tomato', + 'about': ( + 'The tomato (Solanum lycopersicum) is a plant in the nightshade family, native to South ' + 'America. It produces edible, often red, berry-like fruits that are one of the most ' + 'widely consumed vegetables (botanically a fruit) in the world. Tomatoes are rich in ' + 'vitamins C and K, potassium, and lycopene.' + ), + 'used_for': ( + 'Fresh eating, sauces, soups, salads, ketchup, canning, juices, and as a base ' + 'for countless cooked dishes worldwide.' + ), + 'category': Plant.Category.VEGETABLE, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1582284540020-8acbe03f4924?w=800&q=80', + 'image_filename': 'tomato.jpg', + }, + { + 'name': 'Watermelon', + 'about': ( + 'Watermelon (Citrullus lanatus) is a flowering plant species native to tropical and ' + 'subtropical Africa. It produces large, smooth, green-rinded fruits with sweet red or ' + 'yellow flesh, packed with water (about 92%). Watermelons are one of the most popular ' + 'summer fruits worldwide.' + ), + 'used_for': ( + 'Fresh fruit consumption, juices, smoothies, salads, and the seeds are edible when roasted.' + ), + 'category': Plant.Category.FRUIT, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1563114773-84221bd62daa?w=800&q=80', + 'image_filename': 'watermelon.jpg', + }, + { + 'name': 'Basil', + 'about': ( + 'Basil (Ocimum basilicum) is a culinary herb of the family Lamiaceae, native to tropical ' + 'regions from central Africa to Southeast Asia. It is a tender plant highly sensitive to ' + 'cold. Sweet basil is the most commonly grown variety, with fragrant, bright-green leaves ' + 'and small white flowers.' + ), + 'used_for': ( + 'Italian and Asian cuisine (pesto, pasta, salads), herbal teas, aromatherapy, ' + 'and traditional medicine for digestive and anti-inflammatory purposes.' + ), + 'category': Plant.Category.HERB, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=800&q=80', + 'image_filename': 'basil.jpg', + }, + { + 'name': 'Cactus', + 'about': ( + 'Cacti (family Cactaceae) are succulent plants native to the Americas, adapted to ' + 'survive in extremely dry environments. They store water in their thick, fleshy stems ' + 'and are protected by sharp spines. There are over 1,700 species ranging from tiny ' + 'ground-huggers to towering saguaro giants.' + ), + 'used_for': ( + 'Ornamental landscaping and indoor decoration, water storage research, some species ' + 'used in fencing. Prickly pear cactus fruits are edible, though most species are not.' + ), + 'category': Plant.Category.TREE, + 'is_edible': False, + 'image_url': 'https://images.unsplash.com/photo-1459411621453-7b03977f4bfc?w=800&q=80', + 'image_filename': 'cactus.jpg', + }, + { + 'name': 'Lavender', + 'about': ( + 'Lavender (Lavandula) is a genus of 47 known species of flowering plants in the mint ' + 'family. Native to the Old World, it is best known for its beautiful purple flowers and ' + 'calming fragrance. Lavender thrives in sunny, well-drained conditions and is a favourite ' + 'in herb gardens worldwide.' + ), + 'used_for': ( + 'Aromatherapy, essential oils, perfumery, culinary flavouring (baked goods, teas), ' + 'sleep aids, and as an ornamental garden plant.' + ), + 'category': Plant.Category.FLOWER, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1499028344343-cd173ffc68a9?w=800&q=80', + 'image_filename': 'lavender.jpg', + }, + { + 'name': 'Sunflower', + 'about': ( + 'The sunflower (Helianthus annuus) is a large annual forb native to North America. ' + 'It is known for its large, daisy-like flower head up to 30 cm wide with bright yellow ' + 'petals and a central brown disc. Sunflowers are notably heliotropic in their bud stage, ' + 'tracking the sun across the sky.' + ), + 'used_for': ( + 'Sunflower oil production, edible seeds (snacks, baking), birdseed, ' + 'ornamental gardening, and biofuel research.' + ), + 'category': Plant.Category.FLOWER, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1597848212624-a19eb35e2651?w=800&q=80', + 'image_filename': 'sunflower.jpg', + }, + { + 'name': 'Aloe Vera', + 'about': ( + 'Aloe vera is a succulent plant species of the genus Aloe, originally from the Arabian ' + 'Peninsula. It has thick, fleshy, green leaves edged with small teeth. The leaves contain ' + 'a clear gel rich in vitamins, minerals, and antioxidants. It thrives in dry, tropical, ' + 'and semi-tropical climates and is widely cultivated as an ornamental and medicinal plant.' + ), + 'used_for': ( + 'Skin care and cosmetics (gels, lotions, sunburn relief), traditional medicine, ' + 'dietary supplements, and as an ornamental houseplant.' + ), + 'category': Plant.Category.HERB, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1567331711402-509c12c41959?w=800&q=80', + 'image_filename': 'aloe_vera.jpg', + }, + { + 'name': 'Mango', + 'about': ( + 'The mango (Mangifera indica) is a tropical tree native to South Asia and one of the most ' + 'widely cultivated fruits in the world. It belongs to the cashew family Anacardiaceae. ' + 'Mango trees can grow up to 40 metres tall and live for over 300 years. The fruit ranges ' + 'from green to yellow, orange, or red and has juicy, sweet, aromatic flesh.' + ), + 'used_for': ( + 'Fresh fruit consumption, juices, smoothies, pickles, chutneys, dried fruit snacks, ' + 'and traditional Ayurvedic medicine.' + ), + 'category': Plant.Category.FRUIT, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1553279768-865429fa0078?w=800&q=80', + 'image_filename': 'mango.jpg', + }, + { + 'name': 'Tulip', + 'about': ( + 'Tulips (Tulipa) are a genus of spring-blooming perennial herbaceous bulbiferous geophytes ' + 'in the lily family Liliaceae. Native to Central Asia, they were brought to Europe in the ' + '16th century and sparked the famous "Tulip Mania" in the Netherlands. With over 3,000 ' + 'registered cultivars, tulips come in nearly every colour and are one of the world\'s ' + 'most recognised flowers.' + ), + 'used_for': ( + 'Ornamental gardening, cut flower industry, floral arrangements, and symbolic use ' + 'in cultural celebrations and national identity (Netherlands).' + ), + 'category': Plant.Category.FLOWER, + 'is_edible': False, + 'image_url': 'https://images.unsplash.com/photo-1589994965851-a8f479c573a9?w=800&q=80', + 'image_filename': 'tulip.jpg', + }, + { + 'name': 'Garlic', + 'about': ( + 'Garlic (Allium sativum) is a species of bulbous flowering plant in the genus Allium. ' + 'Native to Central Asia, it has been used as both food and traditional medicine for ' + 'thousands of years. The plant grows to about 1 metre in height and produces a bulb ' + 'made up of individual cloves wrapped in papery skin. Its pungent flavour comes from ' + 'organosulfur compounds, especially allicin.' + ), + 'used_for': ( + 'Culinary seasoning worldwide, antibacterial and antifungal medicine, ' + 'cardiovascular health supplements, and as a natural insect repellent in gardens.' + ), + 'category': Plant.Category.VEGETABLE, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1540148426945-6cf22a6b2383?w=800&q=80', + 'image_filename': 'garlic.jpg', + }, + { + 'name': 'Bamboo', + 'about': ( + 'Bamboo is a subfamily of flowering perennial evergreen plants in the grass family Poaceae. ' + 'It is one of the fastest-growing plants on Earth, with some species growing up to 91 cm ' + 'per day. There are over 1,400 bamboo species found across Asia, Africa, and the Americas. ' + 'Despite its grass classification, bamboo can reach heights of over 30 metres.' + ), + 'used_for': ( + 'Construction and flooring, furniture, paper production, edible shoots (bamboo shoots), ' + 'musical instruments, and sustainable packaging material.' + ), + 'category': Plant.Category.TREE, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1509909756405-be0199881695?w=800&q=80', + 'image_filename': 'bamboo.jpg', + }, + { + 'name': 'Strawberry', + 'about': ( + 'The garden strawberry (Fragaria × ananassa) is a widely grown hybrid species of the ' + 'genus Fragaria, first bred in Brittany, France, in the 1750s. It is cultivated worldwide ' + 'for its heart-shaped, bright red, juicy fruit. The plant is a low-growing perennial ' + 'that spreads via runners, producing small white flowers before fruiting.' + ), + 'used_for': ( + 'Fresh eating, jams, jellies, juices, desserts, ice cream, and as a flavouring ' + 'in confectionery and cosmetics.' + ), + 'category': Plant.Category.FRUIT, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1464965911861-746a04b4bca6?w=800&q=80', + 'image_filename': 'strawberry.jpg', + }, + { + 'name': 'Rosemary', + 'about': ( + 'Rosemary (Salvia rosmarinus, formerly Rosmarinus officinalis) is a fragrant evergreen ' + 'herb native to the Mediterranean region. It belongs to the mint family Lamiaceae and ' + 'grows as a woody shrub with needle-like leaves and small blue, pink, or white flowers. ' + 'It has been used in cooking and medicine since ancient times.' + ), + 'used_for': ( + 'Culinary seasoning for meats, breads, and soups; herbal medicine for memory and ' + 'circulation; aromatherapy; and as a garden ornamental.' + ), + 'category': Plant.Category.HERB, + 'is_edible': True, + 'image_url': 'https://images.unsplash.com/photo-1604480133435-25b86862d276?w=800&q=80', + 'image_filename': 'rosemary.jpg', + }, +] + + +class Command(BaseCommand): + help = 'Seed the database with sample plant data (clears existing plants first).' + + def handle(self, *args, **options): + self.stdout.write('Clearing existing plants...') + Plant.objects.all().delete() + + media_plants_dir = os.path.join('media', 'plants') + os.makedirs(media_plants_dir, exist_ok=True) + + for data in PLANTS_DATA: + self.stdout.write(f" Adding {data['name']}...") + filename = data['image_filename'] + local_path = os.path.join(media_plants_dir, filename) + + # Download image if not already cached + if not os.path.exists(local_path): + try: + req = urllib.request.Request( + data['image_url'], + headers={'User-Agent': 'Mozilla/5.0 (PlanteerSeeder/1.0)'} + ) + with urllib.request.urlopen(req, timeout=15) as response: + img_data = response.read() + with open(local_path, 'wb') as f: + f.write(img_data) + self.stdout.write(f" Downloaded image for {data['name']}") + except Exception as e: + self.stdout.write( + self.style.WARNING(f" Could not download image for {data['name']}: {e}") + ) + img_data = None + + plant = Plant( + name=data['name'], + about=data['about'], + used_for=data['used_for'], + category=data['category'], + is_edible=data['is_edible'], + ) + + if os.path.exists(local_path): + with open(local_path, 'rb') as f: + plant.image.save(filename, ContentFile(f.read()), save=False) + + plant.save() + + count = Plant.objects.count() + self.stdout.write(self.style.SUCCESS(f'\nDone! {count} plants seeded successfully.')) diff --git a/Planteer/plants/migrations/0001_initial.py b/Planteer/plants/migrations/0001_initial.py new file mode 100644 index 0000000..0d20552 --- /dev/null +++ b/Planteer/plants/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.4 on 2026-04-18 22:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Plant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('about', models.TextField()), + ('used_for', models.TextField()), + ('image', models.ImageField(upload_to='plants/')), + ('category', models.CharField(choices=[('TREE', 'Tree'), ('FRUIT', 'Fruit'), ('VEGETABLE', 'Vegetable'), ('FLOWER', 'Flower'), ('HERB', 'Herb')], max_length=20)), + ('is_edible', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/Planteer/plants/migrations/0002_comment.py b/Planteer/plants/migrations/0002_comment.py new file mode 100644 index 0000000..80930bc --- /dev/null +++ b/Planteer/plants/migrations/0002_comment.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.4 on 2026-04-25 10:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('content', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('plant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='plants.plant')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/Planteer/plants/migrations/__init__.py b/Planteer/plants/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Planteer/plants/models.py b/Planteer/plants/models.py new file mode 100644 index 0000000..4fa511c --- /dev/null +++ b/Planteer/plants/models.py @@ -0,0 +1,38 @@ +from django.db import models + + +class Plant(models.Model): + class Category(models.TextChoices): + TREE = 'TREE', 'Tree' + FRUIT = 'FRUIT', 'Fruit' + VEGETABLE = 'VEGETABLE', 'Vegetable' + FLOWER = 'FLOWER', 'Flower' + HERB = 'HERB', 'Herb' + + name = models.CharField(max_length=200) + about = models.TextField() + used_for = models.TextField() + # NOTE: pip install Pillow is required for ImageField support + image = models.ImageField(upload_to='plants/') + category = models.CharField(max_length=20, choices=Category.choices) + is_edible = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + class Meta: + ordering = ['-created_at'] + + +class Comment(models.Model): + plant = models.ForeignKey(Plant, on_delete=models.CASCADE, related_name='comments') + name = models.CharField(max_length=100) + content = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.name} on {self.plant.name}" + + class Meta: + ordering = ['-created_at'] diff --git a/Planteer/plants/templates/plants/all_plants.html b/Planteer/plants/templates/plants/all_plants.html new file mode 100644 index 0000000..a6e6f70 --- /dev/null +++ b/Planteer/plants/templates/plants/all_plants.html @@ -0,0 +1,67 @@ +{% extends 'main/base.html' %} + +{% block title %}All Plants — Planteer{% endblock %} + +{% block content %} + + + +
+ +
+ + + + + + + {% if selected_category or selected_edible %} + Clear + {% endif %} +
+ + + {% if plants %} +
+ {% for plant in plants %} + + {% endfor %} +
+ {% else %} +
+

No plants match your filters.

+
+ {% endif %} +
+ +{% endblock %} diff --git a/Planteer/plants/templates/plants/new_plant.html b/Planteer/plants/templates/plants/new_plant.html new file mode 100644 index 0000000..23fab88 --- /dev/null +++ b/Planteer/plants/templates/plants/new_plant.html @@ -0,0 +1,52 @@ +{% extends 'main/base.html' %} + +{% block title %}Add New Plant — Planteer{% endblock %} + +{% block content %} +
+

Add New Plant

+

Fill in the details below to add a new plant to the database.

+ +
+ {% csrf_token %} + +
+ + {{ form.name }} + {{ form.name.errors }} +
+ +
+ + {{ form.category }} + {{ form.category.errors }} +
+ +
+ + {{ form.about }} + {{ form.about.errors }} +
+ +
+ + {{ form.used_for }} + {{ form.used_for.errors }} +
+ +
+ + {{ form.image }} + {{ form.image.errors }} +
+ +
+ {{ form.is_edible }} + + {{ form.is_edible.errors }} +
+ + +
+
+{% endblock %} diff --git a/Planteer/plants/templates/plants/plant_detail.html b/Planteer/plants/templates/plants/plant_detail.html new file mode 100644 index 0000000..33d3e75 --- /dev/null +++ b/Planteer/plants/templates/plants/plant_detail.html @@ -0,0 +1,120 @@ +{% extends 'main/base.html' %} + +{% block title %}{{ plant.name }} — Planteer{% endblock %} + +{% block content %} + + +
+ +
+ {% if plant.image %} + {{ plant.name }} + {% else %} +
No image available
+ {% endif %} +
+ + +
+

{{ plant.get_category_display }}

+

{{ plant.name }}

+ +

About

+

{{ plant.about }}

+ +

Is Edible

+

+ {% if plant.is_edible %} + Yes — Edible + {% else %} + No — Not Edible + {% endif %} +

+ +

Used For

+

{{ plant.used_for }}

+ +
+ Edit + Delete +
+
+
+ + + {% if related_plants %} +
+ + {% endif %} + + +
+
+ +

Comments ({{ comments.count }})

+ + {% if comments %} + {% for comment in comments %} +
+
+ {{ comment.name }} + {{ comment.created_at|date:"N j, Y" }} +
+

{{ comment.content }}

+
+ {% endfor %} + {% else %} +

No comments yet. Be the first to leave feedback!

+ {% endif %} + + +
+

Leave a Comment

+
+ {% csrf_token %} +
+ + {{ comment_form.name }} + {% if comment_form.name.errors %} +

{{ comment_form.name.errors.0 }}

+ {% endif %} +
+
+ + {{ comment_form.content }} + {% if comment_form.content.errors %} +

{{ comment_form.content.errors.0 }}

+ {% endif %} +
+ +
+
+ +
+ +{% endblock %} diff --git a/Planteer/plants/templates/plants/search.html b/Planteer/plants/templates/plants/search.html new file mode 100644 index 0000000..7a2e3eb --- /dev/null +++ b/Planteer/plants/templates/plants/search.html @@ -0,0 +1,57 @@ +{% extends 'main/base.html' %} + +{% block title %}Search Results — Planteer{% endblock %} + +{% block content %} +
+

Search Results

+ + + + {% if query %} +

+ {% if plants %} + {{ plants|length }} result{{ plants|length|pluralize }} for “{{ query }}” + {% else %} + No results for “{{ query }}” + {% endif %} +

+ {% endif %} + + {% if plants %} +
+ {% for plant in plants %} + + {% endfor %} +
+ {% elif query %} +
+

No plants found. Browse all plants

+
+ {% endif %} +
+{% endblock %} diff --git a/Planteer/plants/templates/plants/update_plant.html b/Planteer/plants/templates/plants/update_plant.html new file mode 100644 index 0000000..17e5b10 --- /dev/null +++ b/Planteer/plants/templates/plants/update_plant.html @@ -0,0 +1,61 @@ +{% extends 'main/base.html' %} + +{% block title %}Edit {{ plant.name }} — Planteer{% endblock %} + +{% block content %} +
+

Edit Plant

+

Update the details for {{ plant.name }}.

+ +
+ {% csrf_token %} + +
+ + {{ form.name }} + {{ form.name.errors }} +
+ +
+ + {{ form.category }} + {{ form.category.errors }} +
+ +
+ + {{ form.about }} + {{ form.about.errors }} +
+ +
+ + {{ form.used_for }} + {{ form.used_for.errors }} +
+ +
+ + {% if plant.image %} +
+ Current image of {{ plant.name }} +

Current image — upload a new one to replace it

+
+ {% endif %} + {{ form.image }} + {{ form.image.errors }} +
+ +
+ {{ form.is_edible }} + + {{ form.is_edible.errors }} +
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/Planteer/plants/tests.py b/Planteer/plants/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Planteer/plants/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Planteer/plants/urls.py b/Planteer/plants/urls.py new file mode 100644 index 0000000..3d9a8ab --- /dev/null +++ b/Planteer/plants/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('all/', views.all_plants, name='all_plants'), + path('/detail/', views.plant_detail, name='plant_detail'), + path('new/', views.new_plant, name='new_plant'), + path('/update/', views.update_plant, name='update_plant'), + path('/delete/', views.delete_plant, name='delete_plant'), + path('search/', views.search, name='search'), +] diff --git a/Planteer/plants/views.py b/Planteer/plants/views.py new file mode 100644 index 0000000..8d933e8 --- /dev/null +++ b/Planteer/plants/views.py @@ -0,0 +1,99 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib import messages +from .models import Plant +from .forms import PlantForm, CommentForm + + +def all_plants(request): + plants = Plant.objects.all() + category = request.GET.get('category', '') + is_edible = request.GET.get('is_edible', '') + + if category: + plants = plants.filter(category=category) + if is_edible == 'true': + plants = plants.filter(is_edible=True) + elif is_edible == 'false': + plants = plants.filter(is_edible=False) + + categories = Plant.Category.choices + return render(request, 'plants/all_plants.html', { + 'plants': plants, + 'categories': categories, + 'selected_category': category, + 'selected_edible': is_edible, + }) + + +def plant_detail(request, plant_id): + plant = get_object_or_404(Plant, id=plant_id) + related_plants = Plant.objects.filter(category=plant.category).exclude(id=plant_id)[:3] + comments = plant.comments.all() + + if request.method == 'POST': + comment_form = CommentForm(request.POST) + if comment_form.is_valid(): + comment = comment_form.save(commit=False) + comment.plant = plant + comment.save() + messages.success(request, 'Your comment has been added!') + return redirect('plant_detail', plant_id=plant.id) + else: + messages.error(request, 'Please correct the errors below.') + else: + comment_form = CommentForm() + + return render(request, 'plants/plant_detail.html', { + 'plant': plant, + 'related_plants': related_plants, + 'comments': comments, + 'comment_form': comment_form, + }) + + +def new_plant(request): + if request.method == 'POST': + form = PlantForm(request.POST, request.FILES) + if form.is_valid(): + plant = form.save() + messages.success(request, f'"{plant.name}" has been added successfully!') + return redirect('plant_detail', plant_id=plant.id) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = PlantForm() + return render(request, 'plants/new_plant.html', {'form': form}) + + +def update_plant(request, plant_id): + plant = get_object_or_404(Plant, id=plant_id) + if request.method == 'POST': + form = PlantForm(request.POST, request.FILES, instance=plant) + if form.is_valid(): + form.save() + messages.success(request, f'"{plant.name}" has been updated successfully!') + return redirect('plant_detail', plant_id=plant.id) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = PlantForm(instance=plant) + return render(request, 'plants/update_plant.html', {'form': form, 'plant': plant}) + + +def delete_plant(request, plant_id): + plant = get_object_or_404(Plant, id=plant_id) + name = plant.name + plant.delete() + messages.success(request, f'"{name}" has been deleted.') + return redirect('all_plants') + + +def search(request): + query = request.GET.get('q', '').strip() + plants = Plant.objects.none() + if query: + plants = ( + Plant.objects.filter(name__icontains=query) | + Plant.objects.filter(about__icontains=query) + ).distinct() + return render(request, 'plants/search.html', {'plants': plants, 'query': query})