diff --git a/shared/photoflow/ui/__init__.py b/shared/photoflow/ui/__init__.py index e69de29..f0f2bae 100644 --- a/shared/photoflow/ui/__init__.py +++ b/shared/photoflow/ui/__init__.py @@ -0,0 +1,16 @@ +import os +from flask import Blueprint, Flask +from jinja2 import ChoiceLoader, FileSystemLoader + +TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates") +STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") + + +def register_shared_ui(app: Flask) -> None: + app.jinja_loader = ChoiceLoader([app.jinja_loader, FileSystemLoader(TEMPLATE_DIR)]) + bp = Blueprint("shared_ui", __name__, static_folder=STATIC_DIR, + static_url_path="/shared-static") + app.register_blueprint(bp) + + +__all__ = ["TEMPLATE_DIR", "STATIC_DIR", "register_shared_ui"] diff --git a/shared/photoflow/ui/static/shared.js b/shared/photoflow/ui/static/shared.js new file mode 100644 index 0000000..a5d77a1 --- /dev/null +++ b/shared/photoflow/ui/static/shared.js @@ -0,0 +1,57 @@ +// Generic grid + lightbox behavior shared across photoflow apps. +// Apps spread this into their own Alpine component: { ...photoGrid(), ...appLogic } +function photoGrid() { + return { + focused: null, + lightboxOpen: false, + + cards() { + return [...document.querySelectorAll('.photo-card')] + .filter(c => c.style.display !== 'none'); + }, + + select(el) { + if (this.focused) this.focused.classList.remove('ring-4', 'ring-white', 'z-10'); + this.focused = el; + if (el) { + el.classList.add('ring-4', 'ring-white', 'z-10'); + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + if (this.lightboxOpen) this.updateLightbox(); + } + }, + + selectFirst() { + const cards = this.cards(); + if (cards.length) this.select(cards[0]); + }, + + navigate(dir) { + const cards = this.cards(); + if (!cards.length) return; + const idx = this.focused ? cards.indexOf(this.focused) : -1; + const next = cards[Math.max(0, Math.min(cards.length - 1, idx + dir))]; + if (next) this.select(next); + }, + + openLightbox(el) { + this.select(el); + this.lightboxOpen = true; + document.getElementById('lb').style.display = ''; + this.updateLightbox(); + }, + + closeLightbox() { + this.lightboxOpen = false; + const lb = document.getElementById('lb'); + if (lb) lb.style.display = 'none'; + }, + + updateLightbox() { + const el = this.focused; + if (!el) return; + const img = el.querySelector('img'); + const lbImg = document.getElementById('lb-img'); + if (img && lbImg) lbImg.src = img.src; + }, + }; +} diff --git a/shared/photoflow/ui/templates/base.html b/shared/photoflow/ui/templates/base.html new file mode 100644 index 0000000..249c29f --- /dev/null +++ b/shared/photoflow/ui/templates/base.html @@ -0,0 +1,24 @@ + + + + + + {% block title %}photoflow{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + {% block extra_scripts %}{% endblock %} + + diff --git a/shared/photoflow/ui/templates/macros.html b/shared/photoflow/ui/templates/macros.html new file mode 100644 index 0000000..76430b0 --- /dev/null +++ b/shared/photoflow/ui/templates/macros.html @@ -0,0 +1,30 @@ +{% macro status_badge(status) %} + {% set cls = {'pending':'badge-ghost','approved':'badge-success','non_trip':'badge-neutral', + 'skipped':'badge-warning','merged':'badge-info','split':'badge-info'} %} + {{ status }} +{% endmacro %} + +{% macro confidence_badge(confidence) %} + {% if confidence < 0.4 %} + low + {% elif confidence < 0.75 %} + med + {% else %} + high + {% endif %} +{% endmacro %} + +{% macro lightbox() %} + +{% endmacro %} diff --git a/shared/tests/test_ui.py b/shared/tests/test_ui.py new file mode 100644 index 0000000..2bdbf89 --- /dev/null +++ b/shared/tests/test_ui.py @@ -0,0 +1,24 @@ +import os +from flask import Flask, render_template +from photoflow.ui import TEMPLATE_DIR, STATIC_DIR, register_shared_ui + + +def test_dirs_exist(): + assert os.path.isfile(os.path.join(TEMPLATE_DIR, "base.html")) + assert os.path.isfile(os.path.join(STATIC_DIR, "shared.js")) + + +def test_register_serves_shared_static_and_template(): + app = Flask(__name__) + register_shared_ui(app) + + @app.route("/page") + def page(): + return render_template("base.html") + + client = app.test_client() + r = client.get("/page") + assert r.status_code == 200 + assert b"/shared-static/shared.js" in r.data + js = client.get("/shared-static/shared.js") + assert js.status_code == 200 and b"photoGrid" in js.data