incremental

UI elements, nav bar, themes, ui redesign.
This commit is contained in:
Joshua Hare 2025-04-15 22:06:15 +10:00
parent dcbb57b474
commit 2d97536d77
10 changed files with 754 additions and 318 deletions

View File

@ -1,5 +1,5 @@
# app/routes.py # app/routes.py
from flask import Blueprint, render_template, redirect, url_for, flash, request, send_from_directory, abort # Added send_from_directory and abort from flask import Blueprint, render_template, redirect, url_for, flash, request, send_from_directory, abort
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models import SmartApp, ApplicationType, Category, OSSupport, FHIRSupport, Speciality, PricingLicense, DesignedFor, EHRSupport from app.models import SmartApp, ApplicationType, Category, OSSupport, FHIRSupport, Speciality, PricingLicense, DesignedFor, EHRSupport
@ -23,10 +23,8 @@ ALLOWED_EXTENSIONS = {'jpg', 'png'}
def allowed_file(filename): def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
# New route to serve uploaded files
@gallery_bp.route('/uploads/<path:filename>') @gallery_bp.route('/uploads/<path:filename>')
def uploaded_file(filename): def uploaded_file(filename):
# Use the absolute path to the directory inside the container
absolute_upload_folder = os.path.abspath(UPLOAD_FOLDER) absolute_upload_folder = os.path.abspath(UPLOAD_FOLDER)
logger.debug(f"Attempting to serve file: {filename} from {absolute_upload_folder}") logger.debug(f"Attempting to serve file: {filename} from {absolute_upload_folder}")
try: try:
@ -45,6 +43,19 @@ def gallery():
query = SmartApp.query query = SmartApp.query
filter_params = {} filter_params = {}
# Handle search
search_term = request.args.get('search', '').strip()
if search_term:
query = query.filter(
or_(
SmartApp.name.ilike(f'%{search_term}%'),
SmartApp.description.ilike(f'%{search_term}%'),
SmartApp.developer.ilike(f'%{search_term}%')
)
)
filter_params['search'] = search_term
# Existing filter logic
application_type_ids = request.args.getlist('application_type', type=int) application_type_ids = request.args.getlist('application_type', type=int)
category_ids = request.args.getlist('category', type=int) category_ids = request.args.getlist('category', type=int)
os_support_ids = request.args.getlist('os_support', type=int) os_support_ids = request.args.getlist('os_support', type=int)
@ -145,9 +156,7 @@ def register():
flash('Invalid SMART scopes. Use formats like patient/Patient.read, launch/patient.', 'danger') flash('Invalid SMART scopes. Use formats like patient/Patient.read, launch/patient.', 'danger')
return render_template('register.html', form=form) return render_template('register.html', form=form)
# Ensure upload folder exists
try: try:
# Use the absolute path inside the container
os.makedirs(UPLOAD_FOLDER, exist_ok=True) os.makedirs(UPLOAD_FOLDER, exist_ok=True)
logger.debug(f"Ensured {UPLOAD_FOLDER} exists") logger.debug(f"Ensured {UPLOAD_FOLDER} exists")
except Exception as e: except Exception as e:
@ -155,7 +164,6 @@ def register():
flash('Error creating upload directory.', 'danger') flash('Error creating upload directory.', 'danger')
return render_template('register.html', form=form) return render_template('register.html', form=form)
# Handle new filter options
if form.application_type_new.data: if form.application_type_new.data:
app_type = ApplicationType(name=form.application_type_new.data) app_type = ApplicationType(name=form.application_type_new.data)
db.session.add(app_type) db.session.add(app_type)
@ -197,13 +205,11 @@ def register():
db.session.commit() db.session.commit()
form.ehr_support.data.append(ehr.id) form.ehr_support.data.append(ehr.id)
# Handle logo
logo_url = form.logo_url.data logo_url = form.logo_url.data
if form.logo_upload.data: if form.logo_upload.data:
file = form.logo_upload.data file = form.logo_upload.data
if allowed_file(file.filename): if allowed_file(file.filename):
filename = secure_filename(f"{uuid.uuid4()}_{file.filename}") filename = secure_filename(f"{uuid.uuid4()}_{file.filename}")
# Use absolute path for saving
save_path = os.path.join(UPLOAD_FOLDER, filename) save_path = os.path.join(UPLOAD_FOLDER, filename)
logger.debug(f"Attempting to save logo to {save_path}") logger.debug(f"Attempting to save logo to {save_path}")
try: try:
@ -214,24 +220,20 @@ def register():
logger.error(f"Failed to save logo to {save_path}") logger.error(f"Failed to save logo to {save_path}")
flash('Failed to save logo.', 'danger') flash('Failed to save logo.', 'danger')
return render_template('register.html', form=form) return render_template('register.html', form=form)
# Store URL path for web access logo_url = f"/uploads/{filename}"
logo_url = f"/uploads/{filename}" # CHANGED
logger.debug(f"Set logo_url to {logo_url}") logger.debug(f"Set logo_url to {logo_url}")
except Exception as e: except Exception as e:
logger.error(f"Error saving logo to {save_path}: {e}") logger.error(f"Error saving logo to {save_path}: {e}")
flash('Error saving logo.', 'danger') flash('Error saving logo.', 'danger')
return render_template('register.html', form=form) return render_template('register.html', form=form)
# Handle app images
app_images = [] app_images = []
if form.app_image_urls.data: if form.app_image_urls.data:
# Keep existing URLs if they are valid URLs
app_images.extend([url.strip() for url in form.app_image_urls.data.splitlines() if url.strip().startswith(('http://', 'https://'))]) app_images.extend([url.strip() for url in form.app_image_urls.data.splitlines() if url.strip().startswith(('http://', 'https://'))])
if form.app_image_uploads.data: # Check if a file was uploaded if form.app_image_uploads.data:
file = form.app_image_uploads.data file = form.app_image_uploads.data
if file and allowed_file(file.filename): # Check if file object exists and is allowed if file and allowed_file(file.filename):
filename = secure_filename(f"{uuid.uuid4()}_{file.filename}") filename = secure_filename(f"{uuid.uuid4()}_{file.filename}")
# Use absolute path for saving
save_path = os.path.join(UPLOAD_FOLDER, filename) save_path = os.path.join(UPLOAD_FOLDER, filename)
logger.debug(f"Attempting to save app image to {save_path}") logger.debug(f"Attempting to save app image to {save_path}")
try: try:
@ -242,8 +244,7 @@ def register():
logger.error(f"Failed to save app image to {save_path}") logger.error(f"Failed to save app image to {save_path}")
flash('Failed to save app image.', 'danger') flash('Failed to save app image.', 'danger')
return render_template('register.html', form=form) return render_template('register.html', form=form)
# Store URL path for web access app_images.append(f"/uploads/{filename}")
app_images.append(f"/uploads/{filename}") # CHANGED
except Exception as e: except Exception as e:
logger.error(f"Error saving app image to {save_path}: {e}") logger.error(f"Error saving app image to {save_path}: {e}")
flash('Error saving app image.', 'danger') flash('Error saving app image.', 'danger')
@ -254,7 +255,7 @@ def register():
description=form.description.data, description=form.description.data,
developer=form.developer.data, developer=form.developer.data,
contact_email=form.contact_email.data, contact_email=form.contact_email.data,
logo_url=logo_url or None, # Use the potentially updated logo_url logo_url=logo_url or None,
launch_url=form.launch_url.data, launch_url=form.launch_url.data,
client_id=form.client_id.data, client_id=form.client_id.data,
scopes=scopes, scopes=scopes,
@ -266,7 +267,7 @@ def register():
specialties=','.join(map(str, form.specialties.data)) if form.specialties.data else None, specialties=','.join(map(str, form.specialties.data)) if form.specialties.data else None,
licensing_pricing_id=form.licensing_pricing.data, licensing_pricing_id=form.licensing_pricing.data,
os_support=','.join(map(str, form.os_support.data)) if form.os_support.data else None, os_support=','.join(map(str, form.os_support.data)) if form.os_support.data else None,
app_images=','.join(app_images) if app_images else None, # Use the potentially updated app_images list app_images=','.join(app_images) if app_images else None,
ehr_support=','.join(map(str, form.ehr_support.data)) if form.ehr_support.data else None, ehr_support=','.join(map(str, form.ehr_support.data)) if form.ehr_support.data else None,
user_id=current_user.id user_id=current_user.id
) )
@ -292,7 +293,6 @@ def edit_app(app_id):
return redirect(url_for('gallery.app_detail', app_id=app_id)) return redirect(url_for('gallery.app_detail', app_id=app_id))
form = SmartAppForm(obj=app) form = SmartAppForm(obj=app)
# Pre-populate multi-select fields correctly on GET request
if not form.is_submitted(): if not form.is_submitted():
if app.categories: if app.categories:
form.categories.data = [int(cid) for cid in app.categories.split(',') if cid] form.categories.data = [int(cid) for cid in app.categories.split(',') if cid]
@ -302,14 +302,11 @@ def edit_app(app_id):
form.os_support.data = [int(oid) for oid in app.os_support.split(',') if oid] form.os_support.data = [int(oid) for oid in app.os_support.split(',') if oid]
if app.ehr_support: if app.ehr_support:
form.ehr_support.data = [int(eid) for eid in app.ehr_support.split(',') if eid] form.ehr_support.data = [int(eid) for eid in app.ehr_support.split(',') if eid]
# Pre-populate the image URL textarea
if app.app_images: if app.app_images:
# Filter out internal paths if mixed with URLs, show only URLs or internal paths formatted for web
current_images = [img for img in app.app_images.split(',') if img.startswith(('http://', 'https://', '/uploads/'))] current_images = [img for img in app.app_images.split(',') if img.startswith(('http://', 'https://', '/uploads/'))]
form.app_image_urls.data = '\n'.join(current_images) form.app_image_urls.data = '\n'.join(current_images)
else: else:
form.app_image_urls.data = '' # Ensure it's empty if no images form.app_image_urls.data = ''
if form.validate_on_submit(): if form.validate_on_submit():
scopes = form.scopes.data scopes = form.scopes.data
@ -322,9 +319,7 @@ def edit_app(app_id):
flash('Invalid SMART scopes. Use formats like patient/Patient.read, launch/patient.', 'danger') flash('Invalid SMART scopes. Use formats like patient/Patient.read, launch/patient.', 'danger')
return render_template('edit_app.html', form=form, app=app) return render_template('edit_app.html', form=form, app=app)
# Ensure upload folder exists
try: try:
# Use the absolute path inside the container
os.makedirs(UPLOAD_FOLDER, exist_ok=True) os.makedirs(UPLOAD_FOLDER, exist_ok=True)
logger.debug(f"Ensured {UPLOAD_FOLDER} exists") logger.debug(f"Ensured {UPLOAD_FOLDER} exists")
except Exception as e: except Exception as e:
@ -332,7 +327,6 @@ def edit_app(app_id):
flash('Error creating upload directory.', 'danger') flash('Error creating upload directory.', 'danger')
return render_template('edit_app.html', form=form, app=app) return render_template('edit_app.html', form=form, app=app)
# Handle new filter options (same as register)
if form.application_type_new.data: if form.application_type_new.data:
app_type = ApplicationType(name=form.application_type_new.data) app_type = ApplicationType(name=form.application_type_new.data)
db.session.add(app_type) db.session.add(app_type)
@ -342,14 +336,15 @@ def edit_app(app_id):
category = Category(name=form.categories_new.data) category = Category(name=form.categories_new.data)
db.session.add(category) db.session.add(category)
db.session.commit() db.session.commit()
# Ensure data is list before append if it was None initially if form.categories.data is None:
if form.categories.data is None: form.categories.data = [] form.categories.data = []
form.categories.data.append(category.id) form.categories.data.append(category.id)
if form.os_support_new.data: if form.os_support_new.data:
os_support = OSSupport(name=form.os_support_new.data) os_support = OSSupport(name=form.os_support_new.data)
db.session.add(os_support) db.session.add(os_support)
db.session.commit() db.session.commit()
if form.os_support.data is None: form.os_support.data = [] if form.os_support.data is None:
form.os_support.data = []
form.os_support.data.append(os_support.id) form.os_support.data.append(os_support.id)
if form.fhir_compatibility_new.data: if form.fhir_compatibility_new.data:
fhir = FHIRSupport(name=form.fhir_compatibility_new.data) fhir = FHIRSupport(name=form.fhir_compatibility_new.data)
@ -360,7 +355,8 @@ def edit_app(app_id):
speciality = Speciality(name=form.specialties_new.data) speciality = Speciality(name=form.specialties_new.data)
db.session.add(speciality) db.session.add(speciality)
db.session.commit() db.session.commit()
if form.specialties.data is None: form.specialties.data = [] if form.specialties.data is None:
form.specialties.data = []
form.specialties.data.append(speciality.id) form.specialties.data.append(speciality.id)
if form.licensing_pricing_new.data: if form.licensing_pricing_new.data:
pricing = PricingLicense(name=form.licensing_pricing_new.data) pricing = PricingLicense(name=form.licensing_pricing_new.data)
@ -376,17 +372,15 @@ def edit_app(app_id):
ehr = EHRSupport(name=form.ehr_support_new.data) ehr = EHRSupport(name=form.ehr_support_new.data)
db.session.add(ehr) db.session.add(ehr)
db.session.commit() db.session.commit()
if form.ehr_support.data is None: form.ehr_support.data = [] if form.ehr_support.data is None:
form.ehr_support.data = []
form.ehr_support.data.append(ehr.id) form.ehr_support.data.append(ehr.id)
logo_url = form.logo_url.data
# Handle logo update if form.logo_upload.data:
logo_url = form.logo_url.data # Get URL from form first
if form.logo_upload.data: # If new logo uploaded, it takes precedence
file = form.logo_upload.data file = form.logo_upload.data
if allowed_file(file.filename): if allowed_file(file.filename):
filename = secure_filename(f"{uuid.uuid4()}_{file.filename}") filename = secure_filename(f"{uuid.uuid4()}_{file.filename}")
# Use absolute path for saving
save_path = os.path.join(UPLOAD_FOLDER, filename) save_path = os.path.join(UPLOAD_FOLDER, filename)
logger.debug(f"Attempting to save updated logo to {save_path}") logger.debug(f"Attempting to save updated logo to {save_path}")
try: try:
@ -397,25 +391,20 @@ def edit_app(app_id):
logger.error(f"Failed to save updated logo to {save_path}") logger.error(f"Failed to save updated logo to {save_path}")
flash('Failed to save logo.', 'danger') flash('Failed to save logo.', 'danger')
return render_template('edit_app.html', form=form, app=app) return render_template('edit_app.html', form=form, app=app)
# Store URL path for web access logo_url = f"/uploads/{filename}"
logo_url = f"/uploads/{filename}" # CHANGED
logger.debug(f"Set logo_url to {logo_url}") logger.debug(f"Set logo_url to {logo_url}")
except Exception as e: except Exception as e:
logger.error(f"Error saving updated logo to {save_path}: {e}") logger.error(f"Error saving updated logo to {save_path}: {e}")
flash('Error saving logo.', 'danger') flash('Error saving logo.', 'danger')
return render_template('edit_app.html', form=form, app=app) return render_template('edit_app.html', form=form, app=app)
elif not logo_url: # If no new upload AND URL field is empty, keep existing elif not logo_url:
logo_url = app.logo_url # Keep the old one only if the field is empty logo_url = app.logo_url
# Handle app images update
# Start with URLs provided in the text area
app_images = [url.strip() for url in form.app_image_urls.data.splitlines() if url.strip()] app_images = [url.strip() for url in form.app_image_urls.data.splitlines() if url.strip()]
if form.app_image_uploads.data:
if form.app_image_uploads.data: # Check if a file was uploaded
file = form.app_image_uploads.data file = form.app_image_uploads.data
if file and allowed_file(file.filename): # Check if file object exists and is allowed if file and allowed_file(file.filename):
filename = secure_filename(f"{uuid.uuid4()}_{file.filename}") filename = secure_filename(f"{uuid.uuid4()}_{file.filename}")
# Use absolute path for saving
save_path = os.path.join(UPLOAD_FOLDER, filename) save_path = os.path.join(UPLOAD_FOLDER, filename)
logger.debug(f"Attempting to save updated app image to {save_path}") logger.debug(f"Attempting to save updated app image to {save_path}")
try: try:
@ -426,20 +415,17 @@ def edit_app(app_id):
logger.error(f"Failed to save updated app image to {save_path}") logger.error(f"Failed to save updated app image to {save_path}")
flash('Failed to save app image.', 'danger') flash('Failed to save app image.', 'danger')
return render_template('edit_app.html', form=form, app=app) return render_template('edit_app.html', form=form, app=app)
# Add the new image's URL path app_images.append(f"/uploads/{filename}")
app_images.append(f"/uploads/{filename}") # CHANGED
except Exception as e: except Exception as e:
logger.error(f"Error saving updated app image to {save_path}: {e}") logger.error(f"Error saving updated app image to {save_path}: {e}")
flash('Error saving app image.', 'danger') flash('Error saving app image.', 'danger')
return render_template('edit_app.html', form=form, app=app) return render_template('edit_app.html', form=form, app=app)
# Update app object
app.name = form.name.data app.name = form.name.data
app.description = form.description.data app.description = form.description.data
app.developer = form.developer.data app.developer = form.developer.data
app.contact_email = form.contact_email.data app.contact_email = form.contact_email.data
app.logo_url = logo_url # Use the final determined logo_url app.logo_url = logo_url
app.launch_url = form.launch_url.data app.launch_url = form.launch_url.data
app.client_id = form.client_id.data app.client_id = form.client_id.data
app.scopes = scopes app.scopes = scopes
@ -451,7 +437,7 @@ def edit_app(app_id):
app.specialties = ','.join(map(str, form.specialties.data)) if form.specialties.data else None app.specialties = ','.join(map(str, form.specialties.data)) if form.specialties.data else None
app.licensing_pricing_id = form.licensing_pricing.data app.licensing_pricing_id = form.licensing_pricing.data
app.os_support = ','.join(map(str, form.os_support.data)) if form.os_support.data else None app.os_support = ','.join(map(str, form.os_support.data)) if form.os_support.data else None
app.app_images = ','.join(app_images) if app_images else None # Use the final list of image URLs/paths app.app_images = ','.join(app_images) if app_images else None
app.ehr_support = ','.join(map(str, form.ehr_support.data)) if form.ehr_support.data else None app.ehr_support = ','.join(map(str, form.ehr_support.data)) if form.ehr_support.data else None
try: try:
db.session.commit() db.session.commit()
@ -464,10 +450,8 @@ def edit_app(app_id):
flash('App updated successfully!', 'success') flash('App updated successfully!', 'success')
return redirect(url_for('gallery.app_detail', app_id=app_id)) return redirect(url_for('gallery.app_detail', app_id=app_id))
# Render the edit form on GET or if validation fails
return render_template('edit_app.html', form=form, app=app) return render_template('edit_app.html', form=form, app=app)
@gallery_bp.route('/gallery/delete/<int:app_id>', methods=['POST']) @gallery_bp.route('/gallery/delete/<int:app_id>', methods=['POST'])
@login_required @login_required
def delete_app(app_id): def delete_app(app_id):
@ -486,7 +470,6 @@ def my_listings():
apps = SmartApp.query.filter_by(user_id=current_user.id).all() apps = SmartApp.query.filter_by(user_id=current_user.id).all()
return render_template('my_listings.html', apps=apps) return render_template('my_listings.html', apps=apps)
# Keep the test route as is, it uses placeholder URLs directly
@gallery_bp.route('/test/add') @gallery_bp.route('/test/add')
@login_required @login_required
def add_test_app(): def add_test_app():

View File

@ -1,58 +1,344 @@
<!---app/templates/app_detail.html---> <!---app/templates/app_detail.html--->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ app.name }}{% endblock %} {% block title %}{{ app.name }}{% endblock %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container mt-4">
<h1>{{ app.name }}</h1> <div class="card shadow-sm mx-auto" style="max-width: 900px; border: none; border-radius: 10px; animation: fadeIn 0.5s;">
<div class="card shadow-sm"> <div class="card-body p-4">
<div class="card-body"> <!-- App Icon -->
{% if app.logo_url %} {% if app.logo_url %}
<img src="{{ app.logo_url }}" alt="{{ app.name }} logo" style="max-height: 200px; object-fit: contain; margin-bottom: 1rem;" onerror="this.style.display='none';"> <img src="{{ app.logo_url }}" alt="{{ app.name }} logo" class="d-block mx-auto mb-3" style="width: 100px; height: 100px; object-fit: contain;" onerror="this.src='https://via.placeholder.com/100?text=No+Logo';">
{% else %} {% else %}
<img src="https://via.placeholder.com/200?text=No+Logo" alt="No Logo" style="max-height: 200px; object-fit: contain; margin-bottom: 1rem;"> <img src="https://via.placeholder.com/100?text=No+Logo" alt="No Logo" class="d-block mx-auto mb-3" style="width: 100px; height: 100px; object-fit: contain;">
{% endif %} {% endif %}
<p><strong>Description:</strong> {{ app.description }}</p>
<p><strong>Developer:</strong> {{ app.developer }}</p> <!-- Title -->
<p><strong>Contact Email:</strong> {{ app.contact_email }}</p> <h1 class="text-center mb-2">{{ app.name }}</h1>
<p><strong>Launch URL:</strong> <a href="{{ app.launch_url }}">{{ app.launch_url }}</a></p>
<p><strong>Client ID:</strong> {{ app.client_id }}</p> <!-- Organization -->
<p><strong>Scopes:</strong> {{ app.scopes }}</p> <h5 class="text-center text-muted mb-3">
<a href="{{ url_for('gallery.gallery', search=app.developer) }}" class="text-decoration-none">{{ app.developer }}</a>
</h5>
<!-- Buttons -->
<div class="d-flex justify-content-center gap-2 mb-4">
{% if app.website %} {% if app.website %}
<p><strong>Website:</strong> <a href="{{ app.website }}">{{ app.website }}</a></p> <a href="{{ app.website }}" class="btn btn-primary" aria-label="Visit {{ app.name }} website">Website</a>
{% endif %} {% else %}
{% if app_categories %} <button class="btn btn-primary" disabled>Website</button>
<p><strong>Categories:</strong> {{ app_categories | map(attribute='name') | join(', ') }}</p>
{% endif %}
{% if app_specialties %}
<p><strong>Specialties:</strong> {{ app_specialties | map(attribute='name') | join(', ') }}</p>
{% endif %}
{% if app_os_supports %}
<p><strong>OS Support:</strong> {{ app_os_supports | map(attribute='name') | join(', ') }}</p>
{% endif %}
{% if app_ehr_supports %}
<p><strong>EHR Support:</strong> {{ app_ehr_supports | map(attribute='name') | join(', ') }}</p>
{% endif %} {% endif %}
<a href="{{ app.launch_url }}" class="btn btn-outline-primary" aria-label="Try {{ app.name }} with sandbox data">Try App</a>
</div>
<!-- Separator -->
<hr class="my-4">
<!-- Grid Layout -->
<div class="row g-4">
<!-- Left: Carousel and Selected Fields -->
<div class="col-md-6">
<!-- Carousel -->
{% if app.app_images %} {% if app.app_images %}
<h5>Additional Images:</h5> <div id="appImagesCarousel" class="carousel slide mb-4" data-bs-ride="carousel">
<div class="row"> <div class="carousel-inner">
{% for img_url in app.app_images.split(',') %} {% for img_url in app.app_images.split(',') %}
<div class="col-md-4 mb-3"> <div class="carousel-item {% if loop.first %}active{% endif %}">
<img src="{{ img_url }}" alt="App Image" style="max-height: 150px; object-fit: contain;" onerror="this.style.display='none';"> <img src="{{ img_url }}" class="d-block w-100" alt="App Screenshot {{ loop.index }}" style="max-height: 300px; object-fit: contain;" onerror="this.src='https://via.placeholder.com/300?text=No+Image';">
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<button class="carousel-control-prev" type="button" data-bs-target="#appImagesCarousel" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#appImagesCarousel" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
</button>
</div>
{% else %}
<img src="https://via.placeholder.com/300?text=No+Image" class="d-block w-100 mb-4" alt="No Image" style="max-height: 300px; object-fit: contain;">
{% endif %} {% endif %}
<!-- Description -->
<section class="mb-3">
<h5>Description</h5>
<hr class="small-hr">
<p>{{ app.description or 'No description provided.' }}</p>
</section>
<!-- Contact Email -->
<section class="mb-3">
<h5>Contact Email</h5>
<hr class="small-hr">
<p>{{ app.contact_email or 'Not specified.' }}</p>
</section>
<!-- Client ID -->
<section class="mb-3">
<h5>Client ID</h5>
<hr class="small-hr">
<p>{{ app.client_id or 'Not specified.' }}</p>
</section>
<!-- Scopes -->
<section class="mb-3">
<h5>Scopes</h5>
<hr class="small-hr">
<p>{{ app.scopes or 'Not specified.' }}</p>
</section>
</div>
<!-- Right: Remaining Fields -->
<div class="col-md-6">
<!-- Website -->
<section class="mb-3">
<h5>Website</h5>
<hr class="small-hr">
<p>
{% if app.website %}
<a href="{{ app.website }}" class="text-decoration-none">{{ app.website }}</a>
{% else %}
Not specified.
{% endif %}
</p>
</section>
<!-- Security and Privacy Policy -->
{% if app.website %}
<section class="mb-3">
<h5>Security and Privacy Policy</h5>
<hr class="small-hr">
<p><a href="{{ app.website }}/security-and-privacy" class="text-decoration-none">{{ app.website }}/security-and-privacy</a></p>
</section>
{% endif %}
<!-- Launch URL -->
<section class="mb-3">
<h5>Launch URL</h5>
<hr class="small-hr">
<p><a href="{{ app.launch_url }}" class="text-decoration-none">{{ app.launch_url }}</a></p>
</section>
<!-- Designed For and Application Type -->
<section class="mb-3">
<div class="row g-2">
<div class="col-6">
<h5>Designed For</h5>
<hr class="small-hr">
<p>
{% if app.designed_for %}
<a href="{{ url_for('gallery.gallery', designed_for=app.designed_for_id) }}" class="text-decoration-none">{{ app.designed_for.name }}</a>
{% else %}
Not specified.
{% endif %}
</p>
</div>
<div class="col-6">
<h5>Application Type</h5>
<hr class="small-hr">
<p>
{% if app.application_type %}
<a href="{{ url_for('gallery.gallery', application_type=app.application_type_id) }}" class="text-decoration-none">{{ app.application_type.name }}</a>
{% else %}
Not specified.
{% endif %}
</p>
</div>
</div>
</section>
<!-- FHIR Compatibility and EHR Support -->
<section class="mb-3">
<div class="row g-2">
<div class="col-6">
<h5>FHIR Compatibility</h5>
<hr class="small-hr">
<p>
{% if app.fhir_compatibility %}
<a href="{{ url_for('gallery.gallery', fhir_support=app.fhir_compatibility_id) }}" class="text-decoration-none">{{ app.fhir_compatibility.name }}</a>
{% else %}
Not specified.
{% endif %}
</p>
</div>
<div class="col-6">
<h5>EHR Support</h5>
<hr class="small-hr">
<p>
{% if app_ehr_supports %}
{% for ehr in app_ehr_supports %}
<a href="{{ url_for('gallery.gallery', ehr_support=ehr.id) }}" class="text-decoration-none">{{ ehr.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
Not specified.
{% endif %}
</p>
</div>
</div>
</section>
<!-- Specialties -->
<section class="mb-3">
<h5>Specialties</h5>
<hr class="small-hr">
<p>
{% if app_specialties %}
{% for speciality in app_specialties %}
<a href="{{ url_for('gallery.gallery', speciality=speciality.id) }}" class="text-decoration-none">{{ speciality.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
Not specified.
{% endif %}
</p>
</section>
<!-- OS Support -->
<section class="mb-3">
<h5>OS Support</h5>
<hr class="small-hr">
<p>
{% if app_os_supports %}
{% for os in app_os_supports %}
<a href="{{ url_for('gallery.gallery', os_support=os.id) }}" class="text-decoration-none">{{ os.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
Not specified.
{% endif %}
</p>
</section>
<!-- Categories -->
<section class="mb-3">
<h5>Categories</h5>
<hr class="small-hr">
<p>
{% if app_categories %}
{% for category in app_categories %}
<a href="{{ url_for('gallery.gallery', category=category.id) }}" class="text-decoration-none">{{ category.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
Not specified.
{% endif %}
</p>
</section>
<!-- Licensing & Pricing -->
<section class="mb-3">
<h5>Licensing & Pricing</h5>
<hr class="small-hr">
<p>
{% if app.licensing_pricing %}
<a href="{{ url_for('gallery.gallery', pricing_license=app.licensing_pricing_id) }}" class="text-decoration-none">{{ app.licensing_pricing.name }}</a>
<!-- Placeholder price, configurable later -->
<span class="text-muted">($99/user/month)</span>
{% else %}
Not specified.
{% endif %}
</p>
</section>
</div>
</div>
<!-- Edit/Delete Buttons (for app owners) -->
{% if current_user.is_authenticated and current_user.id == app.user_id %} {% if current_user.is_authenticated and current_user.id == app.user_id %}
<a href="{{ url_for('gallery.edit_app', app_id=app.id) }}" class="btn btn-primary">Edit App</a> <div class="d-flex justify-content-center gap-2 mt-4">
<form action="{{ url_for('gallery.delete_app', app_id=app.id) }}" method="POST" style="display:inline;"> <a href="{{ url_for('gallery.edit_app', app_id=app.id) }}" class="btn btn-primary" aria-label="Edit {{ app.name }}">Edit App</a>
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this app?');">Delete App</button> <form action="{{ url_for('gallery.delete_app', app_id=app.id) }}" method="POST">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this app?');" aria-label="Delete {{ app.name }}">Delete App</button>
</form> </form>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Report Link -->
<div class="mt-3 d-flex justify-content-center">
<a href="mailto:support@smartflare.com?subject=Report Listing: {{ app.name }}" class="text-danger text-decoration-none" aria-label="Report issue with {{ app.name }} listing">
<i class="fas fa-exclamation-circle me-1"></i> Report Listing
</a>
</div> </div>
</div>
<style>
.card {
background-color: #ffffff;
}
html[data-theme="dark"] .card {
background-color: #343a40;
color: #f8f9fa;
}
html[data-theme="dark"] .text-muted {
color: #adb5bd !important;
}
html[data-theme="dark"] hr {
border-top: 1px solid #495057;
}
.small-hr {
border-top: 1px solid #e0e0e0;
margin: 0.5rem 0;
}
html[data-theme="dark"] .small-hr {
border-top: 1px solid #495057;
}
.carousel-control-prev,
.carousel-control-next {
width: 10%;
background: rgba(0, 0, 0, 0.3);
}
.btn-primary {
background-color: #007bff;
border-color: #007bff;
}
html[data-theme="dark"] .btn-primary {
background-color: #4dabf7;
border-color: #4dabf7;
}
.btn-outline-primary {
color: #007bff;
border-color: #007bff;
}
html[data-theme="dark"] .btn-outline-primary {
color: #4dabf7;
border-color: #4dabf7;
}
.btn-outline-primary:hover {
background-color: #007bff;
color: white;
}
html[data-theme="dark"] .btn-outline-primary:hover {
background-color: #4dabf7;
color: white;
}
h5 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
p {
margin-bottom: 0.5rem;
}
.carousel + section {
margin-top: 0;
}
@media (max-width: 576px) {
.card-body {
padding: 1.5rem;
}
.carousel {
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
}
h5 {
font-size: 0.95rem;
}
p {
font-size: 0.85rem;
}
.row.g-2 > .col-6 {
width: 100%;
}
}
</style>
{% endblock %} {% endblock %}

View File

@ -1,11 +1,29 @@
<!--app/templates/base.html-->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <head> <html lang="en">
<head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>SMARTFLARE - Smart App Gallery - {% block title %}{% endblock %}</title> <title>SMARTFLARE - Smart App Gallery - {% block title %}{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<style> <style>
/* Default (Light Theme) Styles */ /* Default (Light Theme) Styles */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f8f9fa;
color: #212529;
display: flex;
flex-direction: column;
min-height: 100vh;
margin: 0;
}
main {
flex-grow: 1;
}
h1, h5 {
font-weight: 600;
}
.navbar-light { .navbar-light {
background-color: #ffffff !important; background-color: #ffffff !important;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
@ -47,7 +65,6 @@
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
/* Add default background/color if needed for light mode explicitly */
background-color: #ffffff; background-color: #ffffff;
color: #212529; color: #212529;
} }
@ -58,36 +75,75 @@
.card-img-top { .card-img-top {
background-color: #f8f9fa; background-color: #f8f9fa;
} }
body {
/* Default body styles for light mode */
background-color: #f8f9fa; /* Example light background */
color: #212529; /* Default text color */
}
.card { .card {
/* Default card styles */
background-color: #ffffff; background-color: #ffffff;
} }
.text-muted { .text-muted {
/* Ensure default */
color: #6c757d !important; color: #6c757d !important;
} }
.form-check-input:checked {
background-color: #007bff;
border-color: #007bff;
}
.form-check-label i {
font-size: 1.2rem;
margin-left: 0.5rem;
}
.navbar-controls {
gap: 0.5rem;
padding-right: 0;
}
.navbar-search input,
.view-toggle {
height: 38px;
}
.navbar-search {
width: 200px;
}
.nav-link.login-link {
line-height: 38px;
padding: 0 0.5rem;
}
/* Footer Styles */
footer {
background-color: #ffffff;
height: 56px;
display: flex;
align-items: center;
padding: 0.5rem 1rem;
border-top: 1px solid #e0e0e0;
}
.footer-left,
.footer-right {
display: inline-flex;
gap: 0.75rem;
align-items: center;
}
.footer-left a,
.footer-right a {
color: #007bff;
text-decoration: none;
font-size: 0.85rem;
}
.footer-left a:hover,
.footer-right a:hover {
text-decoration: underline;
}
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); } from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* Dark Theme Styles - Triggered by html[data-theme="dark"] */ /* Dark Theme Styles */
html[data-theme="dark"] body { html[data-theme="dark"] body {
background-color: #212529; /* Dark background for body */ background-color: #212529;
color: #f8f9fa; /* Light text */ color: #f8f9fa;
} }
html[data-theme="dark"] .navbar-light { html[data-theme="dark"] .navbar-light {
background-color: #343a40 !important; background-color: #343a40 !important;
border-bottom: 1px solid #495057; border-bottom: 1px solid #495057;
} }
html[data-theme="dark"] .navbar-brand { /* Override brand color */ html[data-theme="dark"] .navbar-brand {
color: #4dabf7 !important; color: #4dabf7 !important;
} }
html[data-theme="dark"] .nav-link { html[data-theme="dark"] .nav-link {
@ -96,7 +152,7 @@
html[data-theme="dark"] .nav-link:hover, html[data-theme="dark"] .nav-link:hover,
html[data-theme="dark"] .nav-link.active { html[data-theme="dark"] .nav-link.active {
color: #4dabf7 !important; color: #4dabf7 !important;
border-bottom-color: #4dabf7; /* Ensure border color matches */ border-bottom-color: #4dabf7;
} }
html[data-theme="dark"] .dropdown-menu { html[data-theme="dark"] .dropdown-menu {
background-color: #495057; background-color: #495057;
@ -110,14 +166,14 @@
} }
html[data-theme="dark"] .dropdown-item.active { html[data-theme="dark"] .dropdown-item.active {
background-color: #4dabf7; background-color: #4dabf7;
color: white !important; /* Ensure text stays white */ color: white !important;
} }
html[data-theme="dark"] .app-card { html[data-theme="dark"] .app-card {
background-color: #343a40; background-color: #343a40;
color: #f8f9fa; color: #f8f9fa;
border: 1px solid #495057; /* Optional: add border for contrast */ border: 1px solid #495057;
} }
html[data-theme="dark"] .card { /* General card styling */ html[data-theme="dark"] .card {
background-color: #343a40; background-color: #343a40;
border: 1px solid #495057; border: 1px solid #495057;
} }
@ -127,13 +183,12 @@
html[data-theme="dark"] .text-muted { html[data-theme="dark"] .text-muted {
color: #adb5bd !important; color: #adb5bd !important;
} }
/* Add any other specific dark mode overrides */
html[data-theme="dark"] h1, html[data-theme="dark"] h1,
html[data-theme="dark"] h5 { html[data-theme="dark"] h5 {
color: #f8f9fa; /* Ensure headings are light */ color: #f8f9fa;
} }
html[data-theme="dark"] .form-label { html[data-theme="dark"] .form-label {
color: #f8f9fa; /* Form labels */ color: #f8f9fa;
} }
html[data-theme="dark"] .form-control { html[data-theme="dark"] .form-control {
background-color: #495057; background-color: #495057;
@ -149,7 +204,7 @@
border-color: #6c757d; border-color: #6c757d;
} }
html[data-theme="dark"] .alert-danger { html[data-theme="dark"] .alert-danger {
background-color: #dc3545; /* Keep alerts visible */ background-color: #dc3545;
color: white; color: white;
border-color: #dc3545; border-color: #dc3545;
} }
@ -158,13 +213,24 @@
color: white; color: white;
border-color: #198754; border-color: #198754;
} }
/* Ensure avatar styles work in dark mode if needed */ html[data-theme="dark"] .form-check-input:checked {
background-color: #4dabf7;
border-color: #4dabf7;
}
html[data-theme="dark"] footer {
background-color: #343a40;
border-top: 1px solid #495057;
}
html[data-theme="dark"] .footer-left a,
html[data-theme="dark"] .footer-right a {
color: #4dabf7;
}
.initials-avatar { .initials-avatar {
display: inline-block; display: inline-block;
width: 30px; width: 30px;
height: 30px; height: 30px;
border-radius: 50%; border-radius: 50%;
background-color: #007bff; /* Or another color */ background-color: #007bff;
color: white; color: white;
text-align: center; text-align: center;
line-height: 30px; line-height: 30px;
@ -176,7 +242,41 @@
border-radius: 50%; border-radius: 50%;
vertical-align: middle; vertical-align: middle;
} }
@media (max-width: 576px) {
.navbar-controls {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.navbar-search {
width: 100%;
}
.navbar-search input,
.view-toggle {
width: 100%;
}
.navbar-controls .nav-link,
.navbar-controls .form-check,
.navbar-controls .dropdown {
margin-bottom: 0.5rem;
}
footer {
height: auto;
padding: 0.5rem;
flex-direction: column;
gap: 0.5rem;
}
.footer-left,
.footer-right {
flex-direction: column;
text-align: center;
gap: 0.5rem;
}
.footer-left a,
.footer-right a {
font-size: 0.8rem;
}
}
</style> </style>
</head> </head>
<body> <body>
@ -184,9 +284,10 @@
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('gallery.gallery') }}">SMART App Gallery</a> <a class="navbar-brand" href="{{ url_for('gallery.gallery') }}">SMART App Gallery</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> </button> <span class="navbar-toggler-icon"></span>
<div class="collapse navbar-collapse" id="navbarNav"> </button>
<ul class="navbar-nav me-auto"> <div class="collapse navbar-collapse d-flex justify-content-between" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'gallery.gallery' %}active{% endif %}" href="{{ url_for('gallery.gallery') }}">Gallery</a> <a class="nav-link {% if request.endpoint == 'gallery.gallery' %}active{% endif %}" href="{{ url_for('gallery.gallery') }}">Gallery</a>
</li> </li>
@ -196,37 +297,48 @@
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
<ul class="navbar-nav"> <div class="navbar-controls d-flex align-items-center ms-auto">
<li class="nav-item"> {% if request.endpoint == 'gallery.gallery' %}
<button type="button" class="nav-link theme-toggle" onclick="toggleTheme()" style="background:none; border:none; padding:inherit; cursor:pointer; display:inline-block; line-height:inherit;">Toggle Theme</button> <form class="navbar-search d-flex" method="GET" action="{{ url_for('gallery.gallery') }}">
</li> <input class="form-control form-control-sm me-2" type="search" name="search" placeholder="Search apps..." aria-label="Search apps by name, description, or developer" value="{{ request.args.get('search', '') }}">
<button class="btn btn-outline-primary btn-sm" type="submit"><i class="fas fa-search"></i></button>
</form>
<button class="btn btn-outline-secondary btn-sm view-toggle" id="viewToggle" onclick="toggleView()" aria-label="Toggle between grid and list view">
<i class="fas fa-th"></i> <span id="viewText">Grid View</span>
</button>
{% endif %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="themeToggle" onchange="toggleTheme()" aria-label="Toggle dark mode" {% if request.cookies.get('theme') == 'dark' %}checked{% endif %}>
<label class="form-check-label" for="themeToggle">
<i class="fas fa-sun"></i>
<i class="fas fa-moon d-none"></i>
</label>
</div>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item dropdown"> <div class="dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{% if current_user.email and current_user.email | md5 %} {% if current_user.email and current_user.email | md5 %}
<img src="https://www.gravatar.com/avatar/{{ current_user.email | md5 }}?s=30&d=identicon" class="user-avatar" alt="User Avatar"> <img src="https://www.gravatar.com/avatar/{{ current_user.email | md5 }}?s: not found
{% else %} {% else %}
<span class="initials-avatar">{{ current_user.username[:2] | upper }}</span> <span class="initials-avatar">{{ current_user.username[:2] | upper }}</span>
{% endif %} {% endif %}
{{ current_user.username }} </a> {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li><a class="dropdown-item {% if request.endpoint == 'gallery.my_listings' %}active{% endif %}" href="{{ url_for('gallery.my_listings') }}">My Listings</a></li> <li><a class="dropdown-item {% if request.endpoint == 'gallery.my_listings' %}active{% endif %}" href="{{ url_for('gallery.my_listings') }}">My Listings</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li> <li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
</ul> </ul>
</li> </div>
{% else %} {% else %}
<li class="nav-item"> <a class="nav-link login-link {% if request.endpoint == 'auth.login' %}active{% endif %}" href="{{ url_for('auth.login') }}">Login</a>
<a class="nav-link {% if request.endpoint == 'auth.login' %}active{% endif %}" href="{{ url_for('auth.login') }}">Login</a> <a class="nav-link login-link {% if request.endpoint == 'auth.register' %}active{% endif %}" href="{{ url_for('auth.register') }}">Register</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'auth.register' %}active{% endif %}" href="{{ url_for('auth.register') }}">Register</a>
</li>
{% endif %} {% endif %}
</ul> </div>
</div> </div>
</div> </div>
</nav> </nav>
<main class="flex-grow-1">
<div class="container mt-4"> <div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
@ -240,41 +352,103 @@
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</main>
<footer class="main-footer" role="contentinfo">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center">
<div class="footer-left">
<a href="https://smarthealthit.org/" target="_blank" rel="noreferrer" aria-label="Visit SMART on FHIR website">SMART on FHIR</a>
<a href="/about" aria-label="Learn about SMART App Gallery">What is SMART Gallery</a>
<a href="https://www.hl7.org/fhir/" target="_blank" rel="noreferrer" aria-label="Visit FHIR website">FHIR</a>
<a href="mailto:support@smartflare.com" aria-label="Contact SMARTFLARE support">Contact Us</a>
</div>
<div class="footer-right">
<a href="/documents/privacy.pdf" aria-label="View Privacy Policy">Privacy</a>
<a href="/documents/terms.pdf" aria-label="View Terms of Use">Terms of Use</a>
<a href="/documents/disclaimer.pdf" aria-label="View Disclaimer">Disclaimer</a>
</div>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<script> <script>
function toggleTheme() { function toggleTheme() {
const html = document.documentElement; const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme'); const toggle = document.getElementById('themeToggle');
if (currentTheme === 'dark') { const sunIcon = document.querySelector('.fa-sun');
html.removeAttribute('data-theme'); const moonIcon = document.querySelector('.fa-moon');
localStorage.setItem('theme', 'light'); if (toggle.checked) {
} else {
html.setAttribute('data-theme', 'dark'); html.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark'); localStorage.setItem('theme', 'dark');
sunIcon.classList.add('d-none');
moonIcon.classList.remove('d-none');
} else {
html.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
sunIcon.classList.remove('d-none');
moonIcon.classList.add('d-none');
} }
} }
// Apply the saved theme on initial load function toggleView() {
const container = document.getElementById('appContainer');
const table = document.getElementById('appTable');
const toggle = document.getElementById('viewToggle');
const viewText = document.getElementById('viewText');
if (!container || !table) return;
if (container.classList.contains('d-none')) {
container.classList.remove('d-none');
table.classList.add('d-none');
viewText.textContent = 'Grid View';
toggle.querySelector('i').className = 'fas fa-th';
localStorage.setItem('view', 'grid');
} else {
container.classList.add('d-none');
table.classList.remove('d-none');
viewText.textContent = 'List View';
toggle.querySelector('i').className = 'fas fa-list';
localStorage.setItem('view', 'list');
}
}
function removeFilter(key, value) {
const url = new URL(window.location);
const params = new URLSearchParams(url.search);
if (key === 'search') {
params.delete('search');
} else {
const values = params.getAll(key).filter(v => v !== value.toString());
params.delete(key);
values.forEach(v => params.append(key, v));
}
window.location.href = `${url.pathname}?${params.toString()}`;
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') { const themeToggle = document.getElementById('themeToggle');
const sunIcon = document.querySelector('.fa-sun');
const moonIcon = document.querySelector('.fa-moon');
if (savedTheme === 'dark' && themeToggle) {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
themeToggle.checked = true;
sunIcon.classList.add('d-none');
moonIcon.classList.remove('d-none');
} }
// Optional: Set to light explicitly if needed, though lack of attribute usually defaults to light const savedView = localStorage.getItem('view');
// else { if (savedView === 'list' && document.getElementById('viewToggle')) {
// document.documentElement.removeAttribute('data-theme'); toggleView();
// } }
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
// Add active class to nav links based on current page tooltips.forEach(t => new bootstrap.Tooltip(t));
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
document.querySelectorAll('#navbarNav .nav-link').forEach(link => { document.querySelectorAll('#navbarNav .nav-link').forEach(link => {
if (link.getAttribute('href') === currentPath) { if (link.getAttribute('href') === currentPath) {
link.classList.add('active'); link.classList.add('active');
} else { } else {
link.classList.remove('active'); // Ensure others aren't marked active link.classList.remove('active');
} }
}); });
// Handle dropdown item active state similarly if needed
document.querySelectorAll('#userDropdown + .dropdown-menu .dropdown-item').forEach(link => { document.querySelectorAll('#userDropdown + .dropdown-menu .dropdown-item').forEach(link => {
if (link.getAttribute('href') === currentPath) { if (link.getAttribute('href') === currentPath) {
link.classList.add('active'); link.classList.add('active');
@ -282,7 +456,6 @@
link.classList.remove('active'); link.classList.remove('active');
} }
}); });
}); });
</script> </script>
</body> </body>

View File

@ -1,9 +1,26 @@
<!---app/templates/gallery.html--->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Gallery{% endblock %} {% block title %}Gallery{% endblock %}
{% macro generate_filter_url(endpoint, filter_key, filter_value, filter_params, search) %}
{% set params = {} %}
{% for key, values in filter_params.items() %}
{% if key != filter_key %}
{% for value in values %}
{% if params[key] is not defined %}
{% set _ = params.update({key: [value]}) %}
{% else %}
{% set _ = params[key].append(value) %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% set _ = params.update({filter_key: filter_value}) %}
{% if search %}
{% set _ = params.update({'search': search}) %}
{% endif %}
{{ url_for(endpoint, **params) }}
{% endmacro %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<!-- Sticky Top Filter Bar --> <!-- Sticky Top Filter Bar -->
@ -19,7 +36,7 @@
<div class="collapse navbar-collapse" id="filterNavbar"> <div class="collapse navbar-collapse" id="filterNavbar">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if not filter_params %}active{% endif %}" href="{{ url_for('gallery.gallery') }}">All Apps</a> <a class="nav-link {% if not filter_params and not request.args.get('search') %}active{% endif %}" href="{{ url_for('gallery.gallery') }}">All Apps</a>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if filter_params.get('application_type') %}active{% endif %}" href="#" id="appTypeDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle {% if filter_params.get('application_type') %}active{% endif %}" href="#" id="appTypeDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
@ -28,7 +45,7 @@
<ul class="dropdown-menu" aria-labelledby="appTypeDropdown"> <ul class="dropdown-menu" aria-labelledby="appTypeDropdown">
{% for app_type in application_types %} {% for app_type in application_types %}
<li> <li>
<a class="dropdown-item {% if app_type.id in filter_params.get('application_type', []) %}active{% endif %}" href="{{ url_for('gallery.gallery', application_type=app_type.id, **(filter_params | rejectattr('application_type'))) }}">{{ app_type.name }}</a> <a class="dropdown-item {% if app_type.id in filter_params.get('application_type', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'application_type', app_type.id, filter_params, request.args.get('search', '')) }}">{{ app_type.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -40,7 +57,7 @@
<ul class="dropdown-menu" aria-labelledby="categoryDropdown"> <ul class="dropdown-menu" aria-labelledby="categoryDropdown">
{% for category in categories %} {% for category in categories %}
<li> <li>
<a class="dropdown-item {% if category.id in filter_params.get('category', []) %}active{% endif %}" href="{{ url_for('gallery.gallery', category=category.id, **(filter_params | rejectattr('category'))) }}">{{ category.name }}</a> <a class="dropdown-item {% if category.id in filter_params.get('category', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'category', category.id, filter_params, request.args.get('search', '')) }}">{{ category.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -52,7 +69,7 @@
<ul class="dropdown-menu" aria-labelledby="osSupportDropdown"> <ul class="dropdown-menu" aria-labelledby="osSupportDropdown">
{% for os in os_supports %} {% for os in os_supports %}
<li> <li>
<a class="dropdown-item {% if os.id in filter_params.get('os_support', []) %}active{% endif %}" href="{{ url_for('gallery.gallery', os_support=os.id, **(filter_params | rejectattr('os_support'))) }}">{{ os.name }}</a> <a class="dropdown-item {% if os.id in filter_params.get('os_support', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'os_support', os.id, filter_params, request.args.get('search', '')) }}">{{ os.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -64,7 +81,7 @@
<ul class="dropdown-menu" aria-labelledby="fhirSupportDropdown"> <ul class="dropdown-menu" aria-labelledby="fhirSupportDropdown">
{% for fhir in fhir_supports %} {% for fhir in fhir_supports %}
<li> <li>
<a class="dropdown-item {% if fhir.id in filter_params.get('fhir_support', []) %}active{% endif %}" href="{{ url_for('gallery.gallery', fhir_support=fhir.id, **(filter_params | rejectattr('fhir_support'))) }}">{{ fhir.name }}</a> <a class="dropdown-item {% if fhir.id in filter_params.get('fhir_support', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'fhir_support', fhir.id, filter_params, request.args.get('search', '')) }}">{{ fhir.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -76,7 +93,7 @@
<ul class="dropdown-menu" aria-labelledby="specialityDropdown"> <ul class="dropdown-menu" aria-labelledby="specialityDropdown">
{% for speciality in specialties %} {% for speciality in specialties %}
<li> <li>
<a class="dropdown-item {% if speciality.id in filter_params.get('speciality', []) %}active{% endif %}" href="{{ url_for('gallery.gallery', speciality=speciality.id, **(filter_params | rejectattr('speciality'))) }}">{{ speciality.name }}</a> <a class="dropdown-item {% if speciality.id in filter_params.get('speciality', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'speciality', speciality.id, filter_params, request.args.get('search', '')) }}">{{ speciality.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -88,7 +105,7 @@
<ul class="dropdown-menu" aria-labelledby="pricingDropdown"> <ul class="dropdown-menu" aria-labelledby="pricingDropdown">
{% for pricing in pricing_licenses %} {% for pricing in pricing_licenses %}
<li> <li>
<a class="dropdown-item {% if pricing.id in filter_params.get('pricing_license', []) %}active{% endif %}" href="{{ url_for('gallery.gallery', pricing_license=pricing.id, **(filter_params | rejectattr('pricing_license'))) }}">{{ pricing.name }}</a> <a class="dropdown-item {% if pricing.id in filter_params.get('pricing_license', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'pricing_license', pricing.id, filter_params, request.args.get('search', '')) }}">{{ pricing.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -100,7 +117,7 @@
<ul class="dropdown-menu" aria-labelledby="designedForDropdown"> <ul class="dropdown-menu" aria-labelledby="designedForDropdown">
{% for designed in designed_fors %} {% for designed in designed_fors %}
<li> <li>
<a class="dropdown-item {% if designed.id in filter_params.get('designed_for', []) %}active{% endif %}" href="{{ url_for('gallery.gallery', designed_for=designed.id, **(filter_params | rejectattr('designed_for'))) }}">{{ designed.name }}</a> <a class="dropdown-item {% if designed.id in filter_params.get('designed_for', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'designed_for', designed.id, filter_params, request.args.get('search', '')) }}">{{ designed.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -112,7 +129,7 @@
<ul class="dropdown-menu" aria-labelledby="ehrSupportDropdown"> <ul class="dropdown-menu" aria-labelledby="ehrSupportDropdown">
{% for ehr in ehr_supports %} {% for ehr in ehr_supports %}
<li> <li>
<a class="dropdown-item {% if ehr.id in filter_params.get('ehr_support', []) %}active{% endif %}" href="{{ url_for('gallery.gallery', ehr_support=ehr.id, **(filter_params | rejectattr('ehr_support'))) }}">{{ ehr.name }}</a> <a class="dropdown-item {% if ehr.id in filter_params.get('ehr_support', []) %}active{% endif %}" href="{{ generate_filter_url('gallery.gallery', 'ehr_support', ehr.id, filter_params, request.args.get('search', '')) }}">{{ ehr.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -122,13 +139,30 @@
</div> </div>
</nav> </nav>
<!-- App Cards --> <!-- Filter Chips and App Display -->
<div class="container mt-4"> <div class="container mt-4">
<p>Explore SMART on FHIR apps. Filter above to find the perfect app.</p> <p>Explore SMART on FHIR apps. Filter to find the perfect app.</p>
{% if filter_params or request.args.get('search') %}
<div class="mb-3">
<h5>Active Filters:</h5>
{% for key, values in filter_params.items() %}
{% for value in values %}
{% set item = {'application_type': application_types, 'category': categories, 'os_support': os_supports, 'fhir_support': fhir_supports, 'speciality': specialties, 'pricing_license': pricing_licenses, 'designed_for': designed_fors, 'ehr_support': ehr_supports}[key] | selectattr('id', 'equalto', value) | first %}
{% if item %}
<span class="badge bg-primary me-1 filter-chip" data-key="{{ key }}" data-value="{{ value }}" data-bs-toggle="tooltip" title="Click to remove filter">{{ item.name }} <i class="fas fa-times ms-1" onclick="removeFilter('{{ key }}', {{ value }})"></i></span>
{% endif %}
{% endfor %}
{% endfor %}
{% if request.args.get('search') %}
<span class="badge bg-primary me-1 filter-chip" data-key="search" data-value="{{ request.args.get('search') | urlencode }}" data-bs-toggle="tooltip" title="Click to clear search">Search: {{ request.args.get('search') }} <i class="fas fa-times ms-1" onclick="removeFilter('search', '{{ request.args.get('search') | urlencode }}')"></i></span>
{% endif %}
<a href="{{ url_for('gallery.gallery') }}" class="btn btn-sm btn-outline-danger ms-2">Clear All</a>
</div>
{% endif %}
{% if apps %} {% if apps %}
<div class="row"> <div id="appContainer" class="row">
{% for app in apps %} {% for app in apps %}
<div class="col-md-4 mb-3"> <div class="app-item col-md-4 mb-3" style="animation: fadeIn 0.5s;">
<div class="card app-card shadow-sm h-100"> <div class="card app-card shadow-sm h-100">
{% if app.logo_url %} {% if app.logo_url %}
<img src="{{ app.logo_url }}" class="card-img-top" alt="{{ app.name }} logo" style="max-height: 150px; object-fit: contain; padding: 1rem;" onerror="this.style.display='none';"> <img src="{{ app.logo_url }}" class="card-img-top" alt="{{ app.name }} logo" style="max-height: 150px; object-fit: contain; padding: 1rem;" onerror="this.style.display='none';">
@ -145,73 +179,33 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div id="appTable" class="d-none">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Developer</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for app in apps %}
<tr style="animation: fadeIn 0.5s;">
<td>{{ app.name }}</td>
<td>{{ app.description | truncate(100) }}</td>
<td>{{ app.developer }}</td>
<td>
<a href="{{ url_for('gallery.app_detail', app_id=app.id) }}" class="btn btn-primary btn-sm">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %} {% else %}
<p class="text-muted">No apps match your filters. Try adjusting the filters above.</p> <p class="text-muted">No apps match your filters or search. Try adjusting the criteria.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Custom CSS for Pretty UI -->
<style>
.navbar-light {
background-color: var(--secondary-bg);
border-bottom: 1px solid var(--text-color);
}
.navbar-light .nav-link {
color: var(--text-color);
transition: color 0.2s ease;
}
.navbar-light .nav-link:hover {
color: var(--link-color);
}
.navbar-light .nav-link.active {
color: var(--link-color);
font-weight: bold;
border-bottom: 2px solid var(--link-color);
}
.dropdown-menu {
animation: fadeIn 0.3s ease;
border: none;
box-shadow: 0 4px 12px var(--card-hover-shadow);
border-radius: 8px;
background-color: var(--card-bg);
}
.dropdown-item {
padding: 0.5rem 1rem;
transition: background-color 0.2s ease;
color: var(--text-color);
}
.dropdown-item:hover {
background-color: var(--secondary-bg);
}
.dropdown-item.active {
background-color: var(--link-color);
color: white;
}
.app-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border-radius: 10px;
overflow: hidden;
}
.app-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px var(--card-hover-shadow);
}
.card-img-top {
background-color: var(--secondary-bg);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
[data-theme="dark"] .navbar-light {
border-bottom: 1px solid #495057;
}
[data-theme="dark"] .navbar-light .nav-link {
color: var(--text-color);
}
[data-theme="dark"] .navbar-light .nav-link:hover, [data-theme="dark"] .navbar-light .nav-link.active {
color: var(--link-color);
}
</style>
{% endblock %} {% endblock %}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB