changes
This commit is contained in:
@ -1,3 +1,5 @@
|
||||
import os
|
||||
import importlib.util
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
@ -10,28 +12,33 @@ login_manager = LoginManager()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object('config.Config')
|
||||
app.config.from_object('app.config.Config')
|
||||
|
||||
# Initialize core extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login' # Optional: redirect for @login_required
|
||||
login_manager.login_view = 'auth.login' # Redirect for @login_required
|
||||
|
||||
# Register Blueprints
|
||||
from .core.routes import core
|
||||
from .core.auth import auth
|
||||
app.register_blueprint(core)
|
||||
app.register_blueprint(auth, url_prefix="/auth")
|
||||
# Register error handlers
|
||||
from .errors import bp as errors_bp
|
||||
app.register_blueprint(errors_bp)
|
||||
|
||||
# Register CLI commands
|
||||
from .cli import seed_admin
|
||||
app.cli.add_command(seed_admin)
|
||||
# Plugin auto-loader
|
||||
plugin_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'plugins'))
|
||||
for plugin in os.listdir(plugin_path):
|
||||
full_path = os.path.join(plugin_path, plugin, 'routes.py')
|
||||
if os.path.isfile(full_path):
|
||||
spec = importlib.util.spec_from_file_location(f"plugins.{plugin}.routes", full_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
if hasattr(mod, 'bp'):
|
||||
app.register_blueprint(mod.bp)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from .core.models import User
|
||||
from plugins.auth.models import User
|
||||
return User.query.get(int(user_id))
|
||||
|
35
app/cli.py
35
app/cli.py
@ -1,35 +0,0 @@
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
from . import db
|
||||
from .core.models import User
|
||||
import os
|
||||
|
||||
@click.command('seed-admin')
|
||||
@with_appcontext
|
||||
def seed_admin():
|
||||
"""Seeds only the default admin user unless SEED_EXTRA_DATA=true"""
|
||||
admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com')
|
||||
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
||||
|
||||
if not User.query.filter_by(email=admin_email).first():
|
||||
click.echo(f"[✔] Creating default admin: {admin_email}")
|
||||
user = User(
|
||||
email=admin_email,
|
||||
password_hash=admin_password, # In production, hash this
|
||||
role='admin',
|
||||
is_verified=True
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
else:
|
||||
click.echo("[ℹ] Admin user already exists.")
|
||||
|
||||
if os.getenv("SEED_EXTRA_DATA", "false").lower() == "true":
|
||||
click.echo("[ℹ] SEED_EXTRA_DATA=true, seeding additional demo content...")
|
||||
seed_extra_data()
|
||||
else:
|
||||
click.echo("[✔] Admin-only seed complete. Skipping extras.")
|
||||
|
||||
def seed_extra_data():
|
||||
# Placeholder for future extended seed logic
|
||||
pass
|
27
app/config.py
Normal file
27
app/config.py
Normal file
@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ['SECRET_KEY']
|
||||
UPLOAD_FOLDER = os.environ['UPLOAD_FOLDER']
|
||||
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024)) # default 16MB
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||
|
||||
# MySQL connection parameters from .env
|
||||
MYSQL_USER = os.environ['MYSQL_USER']
|
||||
MYSQL_PASSWORD = os.environ['MYSQL_PASSWORD']
|
||||
MYSQL_HOST = os.environ['MYSQL_HOST']
|
||||
MYSQL_PORT = os.environ.get('MYSQL_PORT', 3306)
|
||||
MYSQL_DB = os.environ['MYSQL_DATABASE']
|
||||
|
||||
# Build the SQLAlchemy database URI
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}"
|
||||
f"@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}"
|
||||
)
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# Optional toggles
|
||||
ENABLE_DB_PRELOAD = os.environ.get('ENABLE_DB_PRELOAD', '0') == '1'
|
||||
ENABLE_DB_SEEDING = os.environ.get('ENABLE_DB_SEEDING', '0') == '1'
|
||||
SEED_EXTRA_DATA = os.environ.get('SEED_EXTRA_DATA', 'false').lower() == 'true'
|
@ -1,38 +0,0 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from .. import db
|
||||
from .models import User
|
||||
|
||||
auth = Blueprint('auth', __name__)
|
||||
|
||||
@auth.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
user = User.query.filter_by(email=request.form['email']).first()
|
||||
if user and check_password_hash(user.password_hash, request.form['password']):
|
||||
login_user(user)
|
||||
return redirect(url_for('core.index'))
|
||||
flash('Invalid email or password.')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
@auth.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if request.method == 'POST':
|
||||
email = request.form['email']
|
||||
password = request.form['password']
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.')
|
||||
else:
|
||||
user = User(email=email, password_hash=generate_password_hash(password))
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash('Account created, please log in.')
|
||||
return redirect(url_for('auth.login'))
|
||||
return render_template('auth/register.html')
|
||||
|
||||
@auth.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('core.index'))
|
@ -1,127 +0,0 @@
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
from .. import db
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
role = db.Column(db.String(50), default='user')
|
||||
is_verified = db.Column(db.Boolean, default=False)
|
||||
excluded_from_analytics = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Optional: relationship to submissions
|
||||
submissions = db.relationship('Submission', backref='user', lazy=True)
|
||||
|
||||
|
||||
class Submission(db.Model):
|
||||
__tablename__ = 'submission'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', name='fk_submission_user_id'),
|
||||
nullable=False
|
||||
)
|
||||
common_name = db.Column(db.String(120), nullable=False)
|
||||
scientific_name = db.Column(db.String(120))
|
||||
price = db.Column(db.Float, nullable=False)
|
||||
source = db.Column(db.String(120))
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
height = db.Column(db.Float)
|
||||
width = db.Column(db.Float)
|
||||
leaf_count = db.Column(db.Integer)
|
||||
potting_mix = db.Column(db.String(255))
|
||||
container_size = db.Column(db.String(120))
|
||||
health_status = db.Column(db.String(50))
|
||||
notes = db.Column(db.Text)
|
||||
plant_id = db.Column(db.Integer)
|
||||
images = db.relationship('SubmissionImage', backref='submission', lazy=True)
|
||||
|
||||
|
||||
class SubmissionImage(db.Model):
|
||||
__tablename__ = 'submission_images'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
submission_id = db.Column(db.Integer, db.ForeignKey('submission.id'), nullable=False)
|
||||
file_path = db.Column(db.String(255), nullable=False)
|
||||
|
||||
|
||||
class PlantCommonName(db.Model):
|
||||
__tablename__ = 'plants_common'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(120), unique=True, nullable=False)
|
||||
|
||||
|
||||
class PlantScientificName(db.Model):
|
||||
__tablename__ = 'plants_scientific'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), unique=True, nullable=False)
|
||||
|
||||
|
||||
class Plant(db.Model):
|
||||
__tablename__ = 'plants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
common_name_id = db.Column(db.Integer, db.ForeignKey('plants_common.id'))
|
||||
scientific_name_id = db.Column(db.Integer, db.ForeignKey('plants_scientific.id'))
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True)
|
||||
is_dead = db.Column(db.Boolean, default=False)
|
||||
date_added = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_by_user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
|
||||
# Relationships
|
||||
updates = db.relationship('PlantUpdate', backref='plant', lazy=True)
|
||||
lineage = db.relationship('PlantLineage', backref='child', lazy=True,
|
||||
foreign_keys='PlantLineage.child_plant_id')
|
||||
|
||||
|
||||
class PlantLineage(db.Model):
|
||||
__tablename__ = 'plant_lineage'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
parent_plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
|
||||
child_plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
|
||||
|
||||
|
||||
class PlantOwnershipLog(db.Model):
|
||||
__tablename__ = 'plant_ownership_log'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date_acquired = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
date_relinquished = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
|
||||
class PlantUpdate(db.Model):
|
||||
__tablename__ = 'plant_updates'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
|
||||
update_type = db.Column(db.String(100))
|
||||
description = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
images = db.relationship('UpdateImage', backref='update', lazy=True)
|
||||
|
||||
|
||||
class UpdateImage(db.Model):
|
||||
__tablename__ = 'update_images'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
update_id = db.Column(db.Integer, db.ForeignKey('plant_updates.id'), nullable=False)
|
||||
file_path = db.Column(db.String(255), nullable=False)
|
||||
|
||||
|
||||
class ImageHeart(db.Model):
|
||||
__tablename__ = 'image_hearts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
submission_image_id = db.Column(db.Integer, db.ForeignKey('submission_images.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class FeaturedImage(db.Model):
|
||||
__tablename__ = 'featured_images'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
submission_image_id = db.Column(db.Integer, db.ForeignKey('submission_images.id'), nullable=False)
|
||||
override_text = db.Column(db.String(255), nullable=True)
|
||||
is_featured = db.Column(db.Boolean, default=True)
|
@ -1,145 +0,0 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash
|
||||
from datetime import datetime
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy import and_
|
||||
from markupsafe import escape
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
|
||||
from .. import db
|
||||
from .models import Submission, SubmissionImage
|
||||
|
||||
core = Blueprint('core', __name__)
|
||||
|
||||
UPLOAD_FOLDER = 'app/static/uploads'
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'}
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@core.route("/health")
|
||||
def health_check():
|
||||
return "OK", 200
|
||||
|
||||
@core.route('/')
|
||||
def index():
|
||||
submissions = Submission.query.order_by(Submission.timestamp.desc()).limit(6).all()
|
||||
use_placeholder = len(submissions) == 0
|
||||
return render_template('index.html', submissions=submissions, use_placeholder=use_placeholder, current_year=datetime.now().year)
|
||||
|
||||
@core.route('/dashboard')
|
||||
@login_required
|
||||
def user_dashboard():
|
||||
return render_template('dashboard.html', current_year=datetime.now().year)
|
||||
|
||||
@core.route('/admin')
|
||||
@login_required
|
||||
def admin_dashboard():
|
||||
if current_user.role != 'admin':
|
||||
return render_template('unauthorized.html'), 403
|
||||
return render_template('admin_dashboard.html', current_year=datetime.now().year)
|
||||
|
||||
@core.route('/search', methods=['GET', 'POST'])
|
||||
def search():
|
||||
query = escape(request.values.get('q', '').strip())
|
||||
source = escape(request.values.get('source', '').strip())
|
||||
min_price = request.values.get('min_price', type=float)
|
||||
max_price = request.values.get('max_price', type=float)
|
||||
from_date = request.values.get('from_date')
|
||||
to_date = request.values.get('to_date')
|
||||
|
||||
if request.method == 'POST' and request.is_json:
|
||||
removed = request.json.get('remove')
|
||||
if removed == "Name":
|
||||
query = ""
|
||||
elif removed == "Source":
|
||||
source = ""
|
||||
elif removed == "Min Price":
|
||||
min_price = None
|
||||
elif removed == "Max Price":
|
||||
max_price = None
|
||||
elif removed == "From":
|
||||
from_date = None
|
||||
elif removed == "To":
|
||||
to_date = None
|
||||
|
||||
filters = []
|
||||
filter_tags = {}
|
||||
|
||||
if query:
|
||||
filters.append(Submission.common_name.ilike(f"%{query}%"))
|
||||
filter_tags['Name'] = query
|
||||
if source:
|
||||
filters.append(Submission.source.ilike(f"%{source}%"))
|
||||
filter_tags['Source'] = source
|
||||
if min_price is not None:
|
||||
filters.append(Submission.price >= min_price)
|
||||
filter_tags['Min Price'] = f"${min_price:.2f}"
|
||||
if max_price is not None:
|
||||
filters.append(Submission.price <= max_price)
|
||||
filter_tags['Max Price'] = f"${max_price:.2f}"
|
||||
if from_date:
|
||||
filters.append(Submission.timestamp >= from_date)
|
||||
filter_tags['From'] = from_date
|
||||
if to_date:
|
||||
filters.append(Submission.timestamp <= to_date)
|
||||
filter_tags['To'] = to_date
|
||||
|
||||
results = Submission.query.filter(and_(*filters)).order_by(Submission.timestamp.desc()).limit(50).all()
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({
|
||||
"results_html": render_template("search_results.html", results=results),
|
||||
"tags_html": render_template("search_tags.html", filter_tags=filter_tags)
|
||||
})
|
||||
|
||||
return render_template('search.html', results=results, filter_tags=filter_tags)
|
||||
|
||||
@core.route('/submit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def submit():
|
||||
if request.method == 'POST':
|
||||
common_name = escape(request.form.get('common_name', '').strip())
|
||||
scientific_name = escape(request.form.get('scientific_name', '').strip())
|
||||
price = request.form.get('price', type=float)
|
||||
source = escape(request.form.get('source', '').strip())
|
||||
timestamp = request.form.get('timestamp') or datetime.utcnow()
|
||||
height = request.form.get('height', type=float)
|
||||
width = request.form.get('width', type=float)
|
||||
leaf_count = request.form.get('leaf_count', type=int)
|
||||
potting_mix = escape(request.form.get('potting_mix', '').strip())
|
||||
container_size = escape(request.form.get('container_size', '').strip())
|
||||
health_status = escape(request.form.get('health_status', '').strip())
|
||||
notes = escape(request.form.get('notes', '').strip())
|
||||
|
||||
new_submission = Submission(
|
||||
user_id=current_user.id,
|
||||
common_name=common_name,
|
||||
scientific_name=scientific_name,
|
||||
price=price,
|
||||
source=source,
|
||||
timestamp=timestamp,
|
||||
height=height,
|
||||
width=width,
|
||||
leaf_count=leaf_count,
|
||||
potting_mix=potting_mix,
|
||||
container_size=container_size,
|
||||
health_status=health_status,
|
||||
notes=notes,
|
||||
)
|
||||
db.session.add(new_submission)
|
||||
db.session.commit()
|
||||
|
||||
files = request.files.getlist('images')
|
||||
for f in files[:5]:
|
||||
if f and allowed_file(f.filename):
|
||||
filename = secure_filename(f.filename)
|
||||
save_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
f.save(save_path)
|
||||
db.session.add(SubmissionImage(submission_id=new_submission.id, file_path=save_path))
|
||||
|
||||
db.session.commit()
|
||||
flash("Submission received!", "success")
|
||||
return redirect(url_for('core.index'))
|
||||
|
||||
return render_template('submit.html')
|
11
app/errors.py
Normal file
11
app/errors.py
Normal file
@ -0,0 +1,11 @@
|
||||
from flask import render_template, Blueprint
|
||||
|
||||
bp = Blueprint('errors', __name__)
|
||||
|
||||
@bp.app_errorhandler(400)
|
||||
def bad_request(error):
|
||||
return render_template('400.html'), 400
|
||||
|
||||
@bp.app_errorhandler(500)
|
||||
def internal_error(error):
|
||||
return render_template('500.html'), 500
|
1
app/templates/404.html
Normal file
1
app/templates/404.html
Normal file
@ -0,0 +1 @@
|
||||
<h1>404 ERROR</h1><p>Something went wrong.</p>
|
1
app/templates/500.html
Normal file
1
app/templates/500.html
Normal file
@ -0,0 +1 @@
|
||||
<h1>500 ERROR</h1><p>Something went wrong.</p>
|
@ -1,5 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Admin Dashboard</h2>
|
||||
<p>You are logged in as an administrator.</p>
|
||||
{% endblock %}
|
@ -1,15 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Login</h2>
|
||||
<form method="POST" action="/login">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
{% endblock %}
|
@ -1,15 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Register</h2>
|
||||
<form method="POST" action="/register">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Register</button>
|
||||
</form>
|
||||
{% endblock %}
|
@ -1,98 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Nature In Pots Community</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('core.index') }}">Nature In Pots</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('core.index') }}">Home</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('core.user_dashboard') }}">Dashboard</a></li>
|
||||
{% if current_user.is_authenticated and current_user.role == 'admin' %}
|
||||
<li class="nav-item"><a class="nav-link text-danger" href="{{ url_for('core.admin_dashboard') }}">Admin Dashboard</a></li>
|
||||
{% endif %}
|
||||
{% block plugin_links %}{% endblock %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav align-items-center">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="filterDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Search
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-3 shadow" style="min-width: 300px;">
|
||||
<form method="GET" action="{{ url_for('core.search') }}">
|
||||
<div class="mb-2">
|
||||
<input class="form-control" type="text" name="q" placeholder="Plant name">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input class="form-control" type="text" name="source" placeholder="Source">
|
||||
</div>
|
||||
<div class="mb-2 d-flex gap-2">
|
||||
<input class="form-control" type="number" name="min_price" placeholder="Min $" step="0.01">
|
||||
<input class="form-control" type="number" name="max_price" placeholder="Max $" step="0.01">
|
||||
</div>
|
||||
<div class="mb-2 d-flex gap-2">
|
||||
<input class="form-control" type="date" name="from_date">
|
||||
<input class="form-control" type="date" name="to_date">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-success w-100" type="submit">Apply Filters</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item ms-3">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item ms-3"><a class="nav-link" href="{{ url_for('auth.login') }}">Login</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.register') }}">Register</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="alert alert-warning">
|
||||
{% for message in messages %}
|
||||
<div>{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© {{ current_year }} Nature In Pots Community. All rights reserved.
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,5 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>User Dashboard</h2>
|
||||
<p>Welcome to your dashboard, {{ current_user.email }}.</p>
|
||||
{% endblock %}
|
@ -1,36 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h1 class="mb-4 text-center">Welcome to the Plant Price Tracker!</h1>
|
||||
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-4">
|
||||
{% if use_placeholder %}
|
||||
{% for i in range(6) %}
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<img src="https://placehold.co/150x150" class="card-img-top" alt="Placeholder Plant {{ i+1 }}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Placeholder Plant {{ i+1 }}</h5>
|
||||
<p class="card-text text-muted">No real submissions yet — submit a plant to see it here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for sub in submissions %}
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<img src="{{ sub.image_url or 'https://placehold.co/150x150' }}" class="card-img-top" alt="{{ sub.common_name }}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ sub.common_name }}</h5>
|
||||
<p class="card-text">
|
||||
<strong>${{ '%.2f'|format(sub.price) }}</strong><br>
|
||||
Source: {{ sub.source }}<br>
|
||||
Date: {{ sub.timestamp.strftime('%Y-%m-%d') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,34 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2 class="mb-3">Search Results</h2>
|
||||
|
||||
<div id="active-filters">
|
||||
{% include 'search_tags.html' %}
|
||||
</div>
|
||||
|
||||
<div id="search-results">
|
||||
{% include 'search_results.html' %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll(".remove-chip").forEach(btn => {
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
const tag = this.dataset.tag;
|
||||
|
||||
fetch("{{ url_for('core.search') }}", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ remove: tag })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
document.getElementById("active-filters").innerHTML = data.tags_html;
|
||||
document.getElementById("search-results").innerHTML = data.results_html;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,21 +0,0 @@
|
||||
{% if results %}
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-4">
|
||||
{% for sub in results %}
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<img src="{{ sub.image_url or 'https://placehold.co/600x400' }}" class="card-img-top" alt="{{ sub.common_name }}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ sub.common_name }}</h5>
|
||||
<p class="card-text">
|
||||
<strong>${{ '%.2f'|format(sub.price) }}</strong><br>
|
||||
Source: {{ sub.source }}<br>
|
||||
Date: {{ sub.timestamp.strftime('%Y-%m-%d') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No results found.</p>
|
||||
{% endif %}
|
@ -1,15 +0,0 @@
|
||||
|
||||
{% if filter_tags %}
|
||||
<div class="mb-3">
|
||||
<strong>Filters:</strong>
|
||||
{% for key, value in filter_tags.items() %}
|
||||
<span class="badge bg-primary me-2">
|
||||
{{ key }}: {{ value }}
|
||||
<button class="btn-close btn-close-white btn-sm remove-chip ms-1" data-tag="{{ key }}" aria-label="Remove {{ key }}"></button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" onclick="document.getElementById('filterDropdown').click();">
|
||||
Edit Filters
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
@ -1,65 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2 class="mb-4">Submit a New Plant</h2>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Common Name *</label>
|
||||
<input type="text" name="common_name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Scientific Name</label>
|
||||
<input type="text" name="scientific_name" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Price ($)*</label>
|
||||
<input type="number" name="price" class="form-control" step="0.01" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Source</label>
|
||||
<input type="text" name="source" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Height (in)</label>
|
||||
<input type="number" name="height" class="form-control" step="0.1">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Width (in)</label>
|
||||
<input type="number" name="width" class="form-control" step="0.1">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Leaf Count</label>
|
||||
<input type="number" name="leaf_count" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Potting Mix</label>
|
||||
<input type="text" name="potting_mix" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Container Size</label>
|
||||
<input type="text" name="container_size" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Health Status</label>
|
||||
<select name="health_status" class="form-select">
|
||||
<option value="">Select</option>
|
||||
<option value="healthy">Healthy</option>
|
||||
<option value="minor damage">Minor Damage</option>
|
||||
<option value="pest observed">Pest Observed</option>
|
||||
<option value="recovering">Recovering</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Upload up to 5 images (jpg/png)</label>
|
||||
<input type="file" name="images" accept=".jpg,.jpeg,.png" class="form-control" multiple>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-success">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
@ -1,5 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Unauthorized</h2>
|
||||
<p>You do not have permission to access this page.</p>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user