commit ade7c5b33c7e678c064fcc26a359b2f964f1cab2 Author: NguyenND Date: Wed Jul 22 14:50:16 2026 -0400 Jul 22 - Initial code diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0bb325b --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Copy to .env and fill in. Do NOT commit .env. +DB_USER=jqc_features +DB_PASSWORD=change_me +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=jqc_features +# Or set a full URI and it overrides the parts above: +# DATABASE_URL=mysql+pymysql://user:pass@127.0.0.1:3306/jqc_features?charset=utf8mb4 + +# Target for the "Request a demo" button +DEMO_CONTACT_URL=mailto:info@ltservicesinc.com + +# --- Admin panel --- +# Sign session cookies + CSRF tokens. Generate: python3 -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=change_me_to_a_long_random_string +ADMIN_USERNAME=admin +# Generate: python3 -c "from werkzeug.security import generate_password_hash as g; print(g('yourpassword'))" +ADMIN_PASSWORD_HASH= +# Set to 1 once served over HTTPS (required for the login cookie to send). Use 0 for plain-HTTP local testing. +SESSION_COOKIE_SECURE=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3aebd66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +.env +instance/ +*.db +static/img/ +static/vid/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..27afd86 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# JQC Features Site + +Single-page, modern site for LT Services' JQC program. Numbered sections; each +topic shows title only until clicked, then expands to reveal text, links, +photos, or videos. + +Stack: **Flask + MySQL + Gunicorn + systemd + Nginx** on Ubuntu 24.04. + +``` +app.py Flask app + Section/Topic models + routes +config.py env-driven config (DB + demo contact URL) +schema.sql MySQL DDL + seed content (safe to re-run) +templates/index.html +static/css/style.css +static/js/main.js accordion expand/collapse +gunicorn.conf.py +deploy/jqc-features.service +deploy/nginx.conf +.env.example +``` + +--- + +## Deploy + +### 1. Database (run once) + +```bash +sudo mysql < schema.sql +sudo mysql -e "CREATE USER IF NOT EXISTS 'jqc_features'@'127.0.0.1' IDENTIFIED BY 'CHANGE_ME'; + GRANT ALL PRIVILEGES ON jqc_features.* TO 'jqc_features'@'127.0.0.1'; + FLUSH PRIVILEGES;" +``` + +`schema.sql` is idempotent — re-running updates seed content in place, never +duplicates rows. + +### 2. Code + +```bash +sudo useradd -r -s /usr/sbin/nologin jqcweb # service account +sudo mkdir -p /opt/jqc-features +sudo rsync -a ./ /opt/jqc-features/ # copy project here +cd /opt/jqc-features + +sudo python3 -m venv venv +sudo ./venv/bin/pip install -r requirements.txt + +sudo cp .env.example .env # then edit .env with the real DB_PASSWORD +sudo chown -R jqcweb:jqcweb /opt/jqc-features +``` + +### 3. Service (systemd — separate from DB step) + +```bash +sudo cp deploy/jqc-features.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now jqc-features +sudo systemctl status jqc-features # confirm active (running) +curl -s http://127.0.0.1:8000/healthz # -> {"status":"ok"} +``` + +### 4. Nginx + +```bash +# edit server_name in deploy/nginx.conf first +sudo cp deploy/nginx.conf /etc/nginx/sites-available/jqc-features +sudo ln -s /etc/nginx/sites-available/jqc-features /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +# TLS: +sudo certbot --nginx -d features.ltservicesinc.com +``` + +--- + +## Adding photos, videos, or links to a topic + +Content lives in the `topic` table. No code change needed — update rows, then +the page picks them up on next load. + +Text only (default): +```sql +UPDATE topic SET body_html='

New copy with bold.

' +WHERE slug='comprehensive-checklists'; +``` + +Add a photo: +```sql +UPDATE topic SET media_type='image', + media_url='/static/img/checklist.jpg', + media_caption='Timestamped inspection photo' +WHERE slug='comprehensive-checklists'; +``` +(Put the file in `static/img/` — create the folder — or use any absolute URL, +e.g. a Cloudflare R2 link.) + +Add an uploaded video: +```sql +UPDATE topic SET media_type='video', media_url='/static/vid/demo.mp4' +WHERE slug='real-time-issue-tracking'; +``` + +Embed a YouTube clip: +```sql +UPDATE topic SET media_type='embed', + media_url='https://www.youtube.com/embed/VIDEO_ID' +WHERE slug='notifications-and-qr-access'; +``` + +Add a button link (works alongside any media): +```sql +UPDATE topic SET link_url='https://jqc.ltservicesinc.com', + link_label='Open the portal' +WHERE slug='shared-inspector-access'; +``` + +`media_type` values: `none`, `image`, `video`, `embed`. + +## Add a whole new section or topic + +```sql +INSERT INTO section (num,title,subtitle,sort_order) +VALUES (5,'New Section','Optional subtitle',50); + +INSERT INTO topic (section_id,slug,title,body_html,media_type,sort_order) +VALUES ((SELECT id FROM section WHERE num=5), + 'my-new-topic','My New Topic','

Content.

','none',10); +``` + +Sections order by `sort_order`; topics order by `sort_order` within a section. + +--- + +## Admin panel (browser editing) + +Instead of SQL, edit content at **`/admin`**. Single admin account, session login, +CSRF-protected forms, and every create/update/delete is written to `audit_log`. + +### One-time setup + +1. **Already-deployed DB?** add the audit table: + ```bash + sudo mysql < add_admin.sql + ``` + (New deploys skip this — `schema.sql` already includes `audit_log`.) + +2. Set these in `/opt/jqc-features/.env`: + ```bash + SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))") + ADMIN_USERNAME=admin + ADMIN_PASSWORD_HASH= + SESSION_COOKIE_SECURE=1 # keep 1 in production (HTTPS) + ``` + Generate the password hash (never store the plaintext): + ```bash + ./venv/bin/python -c "from werkzeug.security import generate_password_hash as g; print(g('YOUR_PASSWORD'))" + ``` + +3. Install the new dependency and restart: + ```bash + sudo ./venv/bin/pip install -r requirements.txt + sudo systemctl restart jqc-features + ``` + +Then visit `https://your-domain/admin`, sign in, and manage content. + +### What you can do + +- **Dashboard** — every section with its topics; edit or delete inline. +- **Add / edit topic** — section, title, slug (auto if blank), body HTML, media + (image / video / embed), caption, an optional link button, and sort order. +- **Add / edit section** — number (`§NN`, unique), title, subtitle, sort order. +- Deleting a section cascades to its topics (with a confirm prompt). + +### Notes + +- `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a + quick plain-HTTP test on the box, set it to `0` — never in production. +- `SECRET_KEY` must be stable and secret; changing it logs everyone out. +- The admin routes live under `/admin`; the public page and `schema.sql` SQL + workflow above still work unchanged. diff --git a/add_admin.sql b/add_admin.sql new file mode 100644 index 0000000..41e7fca --- /dev/null +++ b/add_admin.sql @@ -0,0 +1,17 @@ +-- Additive migration: adds the audit_log table required by the admin panel. +-- Run this against an ALREADY-DEPLOYED jqc_features database that predates the +-- admin feature. Safe to re-run (IF NOT EXISTS). New deploys don't need it — +-- schema.sql already includes this table. + +USE jqc_features; + +CREATE TABLE IF NOT EXISTS audit_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + actor VARCHAR(80) NULL, + action VARCHAR(40) NOT NULL, + entity VARCHAR(40) NOT NULL, + entity_id INT NULL, + detail VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_audit_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..85b1d58 --- /dev/null +++ b/admin.py @@ -0,0 +1,221 @@ +import hmac +import re +from functools import wraps + +from flask import ( + Blueprint, current_app, flash, redirect, render_template, + request, session, url_for, +) +from werkzeug.security import check_password_hash + +from app import db, log_action, Section, Topic + +admin_bp = Blueprint("admin", __name__, url_prefix="/admin") + +MEDIA_TYPES = ("none", "image", "video", "embed") + + +# ------------------------------------------------------------------ auth +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("admin"): + return redirect(url_for("admin.login", next=request.path)) + return view(*args, **kwargs) + return wrapped + + +@admin_bp.route("/login", methods=["GET", "POST"]) +def login(): + if session.get("admin"): + return redirect(url_for("admin.dashboard")) + + if request.method == "POST": + username = request.form.get("username", "") + password = request.form.get("password", "") + cfg = current_app.config + expected_user = cfg.get("ADMIN_USERNAME", "") + pw_hash = cfg.get("ADMIN_PASSWORD_HASH", "") + + user_ok = hmac.compare_digest(username, expected_user) + pass_ok = bool(pw_hash) and check_password_hash(pw_hash, password) + if user_ok and pass_ok: + session.clear() + session["admin"] = username + dest = request.args.get("next", "") + # only allow local admin redirects + if not dest.startswith("/admin"): + dest = url_for("admin.dashboard") + return redirect(dest) + flash("Incorrect username or password.", "error") + + return render_template("admin/login.html") + + +@admin_bp.route("/logout", methods=["POST"]) +def logout(): + session.clear() + flash("Signed out.", "ok") + return redirect(url_for("admin.login")) + + +# ------------------------------------------------------------------ helpers +def _slugify(value): + value = (value or "").strip().lower() + value = re.sub(r"[^\w\s-]", "", value) + value = re.sub(r"[\s_]+", "-", value).strip("-") + return value or "topic" + + +def _unique_slug(base, exclude_id=None): + slug = base + n = 2 + while True: + q = Topic.query.filter_by(slug=slug) + if exclude_id is not None: + q = q.filter(Topic.id != exclude_id) + if not q.first(): + return slug + slug = f"{base}-{n}" + n += 1 + + +def _int(value, default=0): + try: + return int(value) + except (TypeError, ValueError): + return default + + +# ------------------------------------------------------------------ dashboard +@admin_bp.route("/") +@login_required +def dashboard(): + sections = Section.query.order_by(Section.sort_order, Section.num).all() + return render_template("admin/dashboard.html", sections=sections) + + +# ------------------------------------------------------------------ topics +@admin_bp.route("/topic/new", methods=["GET", "POST"]) +@admin_bp.route("/topic/", methods=["GET", "POST"]) +@login_required +def topic_form(topic_id=None): + topic = Topic.query.get_or_404(topic_id) if topic_id else None + sections = Section.query.order_by(Section.sort_order, Section.num).all() + + if request.method == "POST": + f = request.form + title = f.get("title", "").strip() + section_id = _int(f.get("section_id")) + if not title or not section_id: + flash("Title and section are required.", "error") + return render_template( + "admin/topic_form.html", topic=topic, sections=sections, + media_types=MEDIA_TYPES, form=f, + ) + + media_type = f.get("media_type", "none") + if media_type not in MEDIA_TYPES: + media_type = "none" + + slug_input = f.get("slug", "").strip() + base_slug = _slugify(slug_input or title) + slug = _unique_slug(base_slug, exclude_id=topic.id if topic else None) + + is_new = topic is None + if is_new: + topic = Topic() + + topic.section_id = section_id + topic.slug = slug + topic.title = title + topic.body_html = f.get("body_html", "").strip() or None + topic.link_url = f.get("link_url", "").strip() or None + topic.link_label = f.get("link_label", "").strip() or None + topic.media_type = media_type + topic.media_url = f.get("media_url", "").strip() or None + topic.media_caption = f.get("media_caption", "").strip() or None + topic.sort_order = _int(f.get("sort_order"), 0) + + if is_new: + db.session.add(topic) + db.session.commit() + log_action( + session.get("admin"), "create" if is_new else "update", + "topic", topic.id, topic.title, + ) + flash(f"Topic '{topic.title}' saved.", "ok") + return redirect(url_for("admin.dashboard")) + + return render_template( + "admin/topic_form.html", topic=topic, sections=sections, + media_types=MEDIA_TYPES, form=None, + ) + + +@admin_bp.route("/topic//delete", methods=["POST"]) +@login_required +def topic_delete(topic_id): + topic = Topic.query.get_or_404(topic_id) + title, tid = topic.title, topic.id + db.session.delete(topic) + db.session.commit() + log_action(session.get("admin"), "delete", "topic", tid, title) + flash(f"Topic '{title}' deleted.", "ok") + return redirect(url_for("admin.dashboard")) + + +# ------------------------------------------------------------------ sections +@admin_bp.route("/section/new", methods=["GET", "POST"]) +@admin_bp.route("/section/", methods=["GET", "POST"]) +@login_required +def section_form(section_id=None): + section = Section.query.get_or_404(section_id) if section_id else None + + if request.method == "POST": + f = request.form + title = f.get("title", "").strip() + num = _int(f.get("num")) + if not title or not num: + flash("Number and title are required.", "error") + return render_template("admin/section_form.html", section=section, form=f) + + # enforce unique num + clash = Section.query.filter(Section.num == num) + if section: + clash = clash.filter(Section.id != section.id) + if clash.first(): + flash(f"Section number {num} is already in use.", "error") + return render_template("admin/section_form.html", section=section, form=f) + + is_new = section is None + if is_new: + section = Section() + section.num = num + section.title = title + section.subtitle = f.get("subtitle", "").strip() or None + section.sort_order = _int(f.get("sort_order"), num * 10) + + if is_new: + db.session.add(section) + db.session.commit() + log_action( + session.get("admin"), "create" if is_new else "update", + "section", section.id, section.title, + ) + flash(f"Section '{section.title}' saved.", "ok") + return redirect(url_for("admin.dashboard")) + + return render_template("admin/section_form.html", section=section, form=None) + + +@admin_bp.route("/section//delete", methods=["POST"]) +@login_required +def section_delete(section_id): + section = Section.query.get_or_404(section_id) + title, sid = section.title, section.id + db.session.delete(section) # cascades to its topics + db.session.commit() + log_action(session.get("admin"), "delete", "section", sid, title) + flash(f"Section '{title}' and its topics deleted.", "ok") + return redirect(url_for("admin.dashboard")) diff --git a/app.py b/app.py new file mode 100644 index 0000000..0f8a6b7 --- /dev/null +++ b/app.py @@ -0,0 +1,111 @@ +from datetime import datetime + +from flask import Flask, render_template +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import CSRFProtect +from markupsafe import Markup + +from config import Config + +db = SQLAlchemy() +csrf = CSRFProtect() + + +class Section(db.Model): + __tablename__ = "section" + id = db.Column(db.Integer, primary_key=True) + num = db.Column(db.Integer, nullable=False, unique=True) + title = db.Column(db.String(160), nullable=False) + subtitle = db.Column(db.String(255)) + sort_order = db.Column(db.Integer, nullable=False, default=0) + topics = db.relationship( + "Topic", + backref="section", + order_by="Topic.sort_order", + cascade="all, delete-orphan", + ) + + +class Topic(db.Model): + __tablename__ = "topic" + id = db.Column(db.Integer, primary_key=True) + section_id = db.Column( + db.Integer, db.ForeignKey("section.id", ondelete="CASCADE"), nullable=False + ) + slug = db.Column(db.String(80), nullable=False, unique=True) + title = db.Column(db.String(200), nullable=False) + body_html = db.Column(db.Text) + link_url = db.Column(db.String(500)) + link_label = db.Column(db.String(120)) + media_type = db.Column(db.String(16), nullable=False, default="none") + media_url = db.Column(db.String(500)) + media_caption = db.Column(db.String(255)) + sort_order = db.Column(db.Integer, nullable=False, default=0) + + @property + def body(self): + # Content is admin-authored and trusted; render as-is. + return Markup(self.body_html or "") + + @property + def is_youtube(self): + u = (self.media_url or "").lower() + return "youtube.com" in u or "youtu.be" in u + + +class AuditLog(db.Model): + __tablename__ = "audit_log" + id = db.Column(db.Integer, primary_key=True) + actor = db.Column(db.String(80)) + action = db.Column(db.String(40), nullable=False) # create / update / delete + entity = db.Column(db.String(40), nullable=False) # section / topic + entity_id = db.Column(db.Integer) + detail = db.Column(db.String(255)) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + +def log_action(actor, action, entity, entity_id=None, detail=None): + """Record an audit row. MUST be called AFTER db.session.commit() of the + change it describes, so a failed transaction never leaves an orphan log.""" + entry = AuditLog( + actor=actor, action=action, entity=entity, + entity_id=entity_id, detail=detail, + ) + db.session.add(entry) + db.session.commit() + + +def create_app(): + app = Flask(__name__) + app.config.from_object(Config) + db.init_app(app) + csrf.init_app(app) + + # Deferred import avoids a circular import: admin.py imports the models and + # log_action defined above, which are ready by the time create_app() runs. + from admin import admin_bp + app.register_blueprint(admin_bp) + + @app.route("/") + def index(): + sections = ( + Section.query.order_by(Section.sort_order, Section.num).all() + ) + return render_template( + "index.html", + sections=sections, + demo_url=app.config["DEMO_CONTACT_URL"], + ) + + @app.route("/healthz") + def healthz(): + return {"status": "ok"} + + return app + + +app = create_app() + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=8000, debug=True) diff --git a/config.py b/config.py new file mode 100644 index 0000000..8a05c08 --- /dev/null +++ b/config.py @@ -0,0 +1,52 @@ +import os + + +def _load_dotenv(): + """Load KEY=value lines from a .env beside this file into the environment, + with NO variable expansion. Werkzeug password hashes contain '$', which + shell-style interpolation corrupts. Vars already set (e.g. by systemd) win.""" + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") + if not os.path.exists(path): + return + with open(path, encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, val = line.split("=", 1) + key, val = key.strip(), val.strip() + if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'): + val = val[1:-1] + os.environ.setdefault(key, val) + + +_load_dotenv() + + +class Config: + # Build the SQLAlchemy URI from discrete env vars, or accept a full DATABASE_URL. + DB_USER = os.environ.get("DB_USER", "jqc_features") + DB_PASSWORD = os.environ.get("DB_PASSWORD", "") + DB_HOST = os.environ.get("DB_HOST", "127.0.0.1") + DB_PORT = os.environ.get("DB_PORT", "3306") + DB_NAME = os.environ.get("DB_NAME", "jqc_features") + + SQLALCHEMY_DATABASE_URI = os.environ.get( + "DATABASE_URL", + f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4", + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 280} + + # Public contact button target shown in the closing CTA. + DEMO_CONTACT_URL = os.environ.get("DEMO_CONTACT_URL", "mailto:info@ltservicesinc.com") + + # --- Admin / session --- + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-insecure-change-me") + + ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin") + ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "") + + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" + SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "1") == "1" \ No newline at end of file diff --git a/deploy/jqc-features.service b/deploy/jqc-features.service new file mode 100644 index 0000000..9db6a83 --- /dev/null +++ b/deploy/jqc-features.service @@ -0,0 +1,16 @@ +[Unit] +Description=JQC Features marketing site (Gunicorn) +After=network.target mysql.service + +[Service] +Type=simple +User=jqcweb +Group=jqcweb +WorkingDirectory=/opt/jqc-features +EnvironmentFile=/opt/jqc-features/.env +ExecStart=/opt/jqc-features/venv/bin/gunicorn -c gunicorn.conf.py +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..3aae3d3 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,21 @@ +server { + listen 80; + server_name features.ltservicesinc.com; # <-- change to your domain + + # Serve static assets directly from Nginx (faster than proxying). + location /static/ { + alias /opt/jqc-features/static/; + expires 7d; + access_log off; + } + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# After DNS points at this box, add TLS with: sudo certbot --nginx -d features.ltservicesinc.com diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..70310ff --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,8 @@ +bind = "127.0.0.1:8000" +workers = 3 +worker_class = "sync" +timeout = 30 +accesslog = "-" +errorlog = "-" +# app.py exposes `app` +wsgi_app = "app:app" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d58fae2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +Flask-WTF==1.2.1 +PyMySQL==1.1.1 +gunicorn==22.0.0 diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..c774e9d --- /dev/null +++ b/schema.sql @@ -0,0 +1,100 @@ +-- JQC Features site schema + seed +-- MySQL 8.0 / Ubuntu 24.04 +-- Safe to re-run: uses IF NOT EXISTS and idempotent seed inserts. + +CREATE DATABASE IF NOT EXISTS jqc_features + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE jqc_features; + +CREATE TABLE IF NOT EXISTS section ( + id INT AUTO_INCREMENT PRIMARY KEY, + num INT NOT NULL, + title VARCHAR(160) NOT NULL, + subtitle VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + UNIQUE KEY uq_section_num (num) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS topic ( + id INT AUTO_INCREMENT PRIMARY KEY, + section_id INT NOT NULL, + slug VARCHAR(80) NOT NULL, + title VARCHAR(200) NOT NULL, + body_html MEDIUMTEXT NULL, -- rich text content + link_url VARCHAR(500) NULL, -- optional external link + link_label VARCHAR(120) NULL, + media_type ENUM('none','image','video','embed') NOT NULL DEFAULT 'none', + media_url VARCHAR(500) NULL, -- image src, video src, or embed URL + media_caption VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + UNIQUE KEY uq_topic_slug (slug), + KEY idx_topic_section (section_id), + CONSTRAINT fk_topic_section FOREIGN KEY (section_id) + REFERENCES section(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS audit_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + actor VARCHAR(80) NULL, + action VARCHAR(40) NOT NULL, -- create / update / delete + entity VARCHAR(40) NOT NULL, -- section / topic + entity_id INT NULL, + detail VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_audit_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------ +-- Seed data (idempotent). Re-running updates content in place. +-- ------------------------------------------------------------------ + +INSERT INTO section (num, title, subtitle, sort_order) VALUES + (1, 'JQC Inspectors', 'What the Inspector app does in the field', 10), + (2, 'Collaborative Quality Control', 'One program, both parties', 20), + (3, 'The App Does the Rest', 'Automatic communication and QR access', 30), + (4, 'Customer Benefit', 'What you get out of it', 40) +ON DUPLICATE KEY UPDATE + title=VALUES(title), subtitle=VALUES(subtitle), sort_order=VALUES(sort_order); + +INSERT INTO topic (section_id, slug, title, body_html, link_url, link_label, media_type, media_url, media_caption, sort_order) VALUES + ((SELECT id FROM section WHERE num=1), 'comprehensive-checklists', 'Comprehensive Checklists', + '

Full checklists equipped with timestamped photos, ratings, and captured signatures — ensuring transparency and accountability on every visit.

', + NULL, NULL, 'none', NULL, NULL, 10), + + ((SELECT id FROM section WHERE num=1), 'real-time-issue-tracking', 'Real-Time Issue Tracking', + '

If any issue arises during an inspection, it is flagged on-the-spot and linked to the exact location where it was found. Standalone issues can be raised, and a re-inspection can be initiated from any previous visit.

', + NULL, NULL, 'none', NULL, NULL, 20), + + ((SELECT id FROM section WHERE num=1), 'seamless-history-access', 'Seamless History Access', + '

Inspectors review the full inspection history right on their device, making progress tracking efficient and straightforward.

', + NULL, NULL, 'none', NULL, NULL, 30), + + ((SELECT id FROM section WHERE num=1), 'scheduled-work-made-easy', 'Scheduled Work Made Easy', + '

See all scheduled work and get started with the facility and checklist already selected — no setup, just go.

', + NULL, NULL, 'none', NULL, NULL, 40), + + ((SELECT id FROM section WHERE num=1), 'status-updates-with-evidence', 'Status Updates with Evidence', + '

Update an issue''s status and attach photos of the fix, streamlining the resolution process end to end.

', + NULL, NULL, 'none', NULL, NULL, 50), + + ((SELECT id FROM section WHERE num=2), 'shared-inspector-access', 'Shared Inspector Access', + '

JQC supports the intake of both parties — LT staff and the customer''s QC inspector. Your contract administration inspector can use our JQC apps to perform inspections with the same access as an LT inspector.

', + NULL, NULL, 'none', NULL, NULL, 10), + + ((SELECT id FROM section WHERE num=2), 'director-visibility-sla-alerts', 'Director-Level Visibility & SLA Alerts', + '

The customer contract administrator has every piece of information at their fingertips — the same as our director level — and receives Service Level Agreement (SLA) alerts automatically.

', + NULL, NULL, 'none', NULL, NULL, 20), + + ((SELECT id FROM section WHERE num=3), 'notifications-and-qr-access', 'Automatic Notifications & QR Access', + '

Communication is effortless. The app automatically notifies everyone involved about new inspections, flags issues, and keeps directors informed of any high-risk SLA concerns.

A simple scan of a QR code lets you or your tenants review QC history and report issues directly from a mobile device — no account or login required.

', + NULL, NULL, 'none', NULL, NULL, 10), + + ((SELECT id FROM section WHERE num=4), 'unified-no-cost-platform', 'Unified, No-Cost Platform', + '

Enjoy a unified platform that is paperless, maintenance-free, and backed by complimentary training and support. You are always in the loop about when and where actions are taken.

No subscription charge and no hidden fees.

', + NULL, NULL, 'none', NULL, NULL, 10) +ON DUPLICATE KEY UPDATE + title=VALUES(title), body_html=VALUES(body_html), link_url=VALUES(link_url), + link_label=VALUES(link_label), media_type=VALUES(media_type), + media_url=VALUES(media_url), media_caption=VALUES(media_caption), + sort_order=VALUES(sort_order); diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 0000000..9d5393a --- /dev/null +++ b/static/css/admin.css @@ -0,0 +1,175 @@ +:root{ + --paper:#F7F5F0; + --card:#FFFFFF; + --ink:#12233A; + --ink-soft:#2A3B50; + --muted:#6B7480; + --hair:#E1DDD3; + --hair-2:#EDEAE2; + --aqua:#17B0A6; + --aqua-deep:#0E6E68; + --signal:#E8963A; + --danger:#C24A3A; + --radius:12px; +} +*{box-sizing:border-box} +body{ + margin:0;background:var(--paper);color:var(--ink); + font-family:"IBM Plex Sans",system-ui,sans-serif;font-size:15px;line-height:1.55; + -webkit-font-smoothing:antialiased; +} +a{color:var(--aqua-deep)} +.muted{color:var(--muted)} +code{font-family:"IBM Plex Mono",monospace;font-size:.85em;background:var(--hair-2);padding:1px 5px;border-radius:5px} + +/* nav */ +.anav{ + display:flex;align-items:center;justify-content:space-between; + padding:14px 26px;background:var(--ink);color:var(--paper); + position:sticky;top:0;z-index:10; +} +.anav__brand{ + font-family:"Bricolage Grotesque",sans-serif;font-weight:700;font-size:1.2rem; + color:var(--paper);text-decoration:none;letter-spacing:-.01em; +} +.anav__brand span{ + font-family:"IBM Plex Mono",monospace;font-weight:500;font-size:.7rem; + letter-spacing:.18em;text-transform:uppercase;color:var(--aqua);margin-left:8px; +} +.anav__right{display:flex;align-items:center;gap:16px} +.anav__link{color:rgba(247,245,240,.8);text-decoration:none;font-size:.86rem} +.anav__link:hover{color:var(--aqua)} +.anav__user{ + font-family:"IBM Plex Mono",monospace;font-size:.78rem;color:rgba(247,245,240,.55); +} +.anav form{margin:0} + +/* layout */ +.awrap{max-width:900px;margin:0 auto;padding:32px 26px 80px} + +.page-head{ + display:flex;align-items:flex-start;justify-content:space-between;gap:20px; + margin-bottom:26px;flex-wrap:wrap; +} +.page-head h1{ + font-family:"Bricolage Grotesque",sans-serif;font-weight:700; + font-size:1.9rem;letter-spacing:-.02em;margin:0 0 4px; +} +.page-head__actions{display:flex;gap:10px} + +/* flashes */ +.flashes{margin-bottom:20px;display:flex;flex-direction:column;gap:8px} +.flash{padding:11px 15px;border-radius:10px;font-size:.9rem;border:1px solid transparent} +.flash--ok{background:#E7F6F4;border-color:#B9E6E0;color:#0A5A54} +.flash--error{background:#FBEAE6;border-color:#F0C7BE;color:#9C3325} + +/* buttons */ +.btn{ + display:inline-flex;align-items:center;gap:6px;cursor:pointer;text-decoration:none; + font-family:"IBM Plex Sans",sans-serif;font-weight:600;font-size:.88rem; + padding:9px 16px;border-radius:100px;border:1px solid transparent;line-height:1; + transition:background .18s,border-color .18s,color .18s,transform .18s; +} +.btn--sm{padding:6px 12px;font-size:.8rem} +.btn--primary{background:var(--aqua);color:#04201E} +.btn--primary:hover{background:var(--aqua-deep);color:#fff} +.btn--ghost{background:var(--card);border-color:var(--hair);color:var(--ink-soft)} +.btn--ghost:hover{border-color:var(--aqua);color:var(--aqua-deep)} +.btn--danger{background:transparent;border-color:var(--hair);color:var(--danger)} +.btn--danger:hover{background:var(--danger);border-color:var(--danger);color:#fff} +form{display:inline} + +/* section cards */ +.sec-card{ + background:var(--card);border:1px solid var(--hair);border-radius:var(--radius); + margin-bottom:18px;overflow:hidden; +} +.sec-card__head{ + display:flex;align-items:center;justify-content:space-between;gap:14px; + padding:16px 18px;border-bottom:1px solid var(--hair-2);flex-wrap:wrap; +} +.sec-card__meta{display:flex;align-items:center;gap:12px;flex-wrap:wrap} +.sec-card__meta h2{ + font-family:"Bricolage Grotesque",sans-serif;font-weight:600;font-size:1.2rem;margin:0; +} +.sec-card__actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap} +.sec-card__empty{padding:14px 18px;margin:0} +.pill{ + font-family:"IBM Plex Mono",monospace;font-size:.74rem;font-weight:600; + color:var(--aqua-deep);background:#E7F6F4;padding:4px 9px;border-radius:7px; +} + +/* topic rows */ +.topic-rows{list-style:none;margin:0;padding:0} +.topic-row{ + display:flex;align-items:center;justify-content:space-between;gap:14px; + padding:12px 18px;border-bottom:1px solid var(--hair-2); +} +.topic-row:last-child{border-bottom:0} +.topic-row__main{display:flex;align-items:center;gap:12px;min-width:0;flex-wrap:wrap} +.topic-row__order{ + font-family:"IBM Plex Mono",monospace;font-size:.76rem;color:var(--muted); + min-width:2ch;text-align:right; +} +.topic-row__title{font-weight:600;color:var(--ink);text-decoration:none} +.topic-row__title:hover{color:var(--aqua-deep)} +.topic-row__actions{display:flex;gap:8px;flex:none} +.chip{ + font-family:"IBM Plex Mono",monospace;font-size:.68rem;letter-spacing:.03em; + text-transform:uppercase;color:var(--muted);border:1px solid var(--hair); + padding:2px 7px;border-radius:6px; +} +.chip--link{color:var(--aqua-deep);border-color:#B9E6E0} + +.empty{ + background:var(--card);border:1px dashed var(--hair);border-radius:var(--radius); + padding:36px;text-align:center;color:var(--muted); +} + +/* forms */ +.form-card{ + background:var(--card);border:1px solid var(--hair);border-radius:var(--radius); + padding:24px; +} +.stack{display:flex;flex-direction:column;gap:16px} +.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px} +.field{display:flex;flex-direction:column;gap:6px} +.field>span{font-weight:600;font-size:.86rem} +.field input,.field select,.field textarea{ + font-family:inherit;font-size:.94rem;color:var(--ink); + padding:10px 12px;border:1px solid var(--hair);border-radius:9px;background:#fff; + width:100%;transition:border-color .18s,box-shadow .18s; +} +.field textarea{font-family:"IBM Plex Mono",monospace;font-size:.86rem;line-height:1.5;resize:vertical} +.field input:focus,.field select:focus,.field textarea:focus{ + outline:none;border-color:var(--aqua);box-shadow:0 0 0 3px rgba(23,176,166,.15); +} +.fieldset{border:1px solid var(--hair-2);border-radius:10px;padding:16px;margin:0;display:flex;flex-direction:column;gap:14px} +.fieldset legend{ + font-family:"IBM Plex Mono",monospace;font-size:.72rem;letter-spacing:.1em; + text-transform:uppercase;color:var(--muted);padding:0 6px; +} +.hint{font-size:.8rem;margin:0} +.form-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px} + +/* login */ +.login-body{display:grid;place-items:center;min-height:100vh} +.login-card{ + background:var(--card);border:1px solid var(--hair);border-radius:16px; + padding:36px 32px;width:min(360px,90vw);box-shadow:0 20px 50px -30px rgba(18,35,58,.5); +} +.login-brand{ + font-family:"Bricolage Grotesque",sans-serif;font-weight:700;font-size:1.6rem; + letter-spacing:-.02em; +} +.login-brand span{ + font-family:"IBM Plex Mono",monospace;font-weight:500;font-size:.7rem; + letter-spacing:.18em;text-transform:uppercase;color:var(--aqua-deep);margin-left:8px; +} +.login-hint{color:var(--muted);font-size:.9rem;margin:6px 0 22px} +.login-card .btn{width:100%;justify-content:center;margin-top:4px} + +@media (max-width:620px){ + .grid-2{grid-template-columns:1fr} + .page-head{flex-direction:column} +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..20c8d6f --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,219 @@ +:root{ + --paper:#F7F5F0; + --paper-2:#EFEBE2; + --ink:#12233A; + --ink-soft:#2A3B50; + --muted:#5B6570; + --hair:#DAD6CC; + --aqua:#17B0A6; + --aqua-deep:#0E6E68; + --signal:#E8963A; + --white:#FFFFFF; + --radius:14px; + --maxw:1080px; + --ease:cubic-bezier(.22,.61,.36,1); +} + +*{box-sizing:border-box} +html{scroll-behavior:smooth} +body{ + margin:0; + background:var(--paper); + color:var(--ink); + font-family:"IBM Plex Sans",system-ui,sans-serif; + font-size:16px; + line-height:1.6; + -webkit-font-smoothing:antialiased; + overflow-x:hidden; +} + +/* subtle paper grain */ +.grain{ + position:fixed;inset:0;pointer-events:none;z-index:0;opacity:.5; + background-image:radial-gradient(var(--hair) .5px,transparent .5px); + background-size:4px 4px; + mix-blend-mode:multiply; +} + +/* ---------- HERO ---------- */ +.hero{ + position:relative;z-index:1; + max-width:var(--maxw);margin:0 auto; + padding:88px 28px 56px; +} +.hero__inner{max-width:720px} +.hero__eyebrow{ + font-family:"IBM Plex Mono",monospace; + font-size:.74rem;letter-spacing:.14em;text-transform:uppercase; + color:var(--aqua-deep);display:flex;align-items:center;gap:.6em;margin-bottom:26px; +} +.hero__eyebrow .dot{width:8px;height:8px;border-radius:50%;background:var(--aqua); + box-shadow:0 0 0 4px rgba(23,176,166,.18)} +.hero__title{ + font-family:"Bricolage Grotesque",sans-serif; + font-weight:800;line-height:.92;letter-spacing:-.03em; + font-size:clamp(4rem,14vw,8.5rem); + margin:0;color:var(--ink);display:flex;flex-direction:column; +} +.hero__title-sub{ + font-family:"IBM Plex Mono",monospace; + font-weight:500;font-size:clamp(.8rem,2.4vw,1.05rem); + letter-spacing:.28em;text-transform:uppercase;color:var(--muted); + margin-top:14px;padding-left:.25em; +} +.hero__lede{ + font-size:clamp(1.08rem,2.2vw,1.32rem); + color:var(--ink-soft);max-width:56ch;margin:30px 0 0; +} +.hero__meta{display:flex;flex-wrap:wrap;gap:10px;margin-top:32px} +.tag{ + font-family:"IBM Plex Mono",monospace;font-size:.76rem;letter-spacing:.04em; + padding:7px 13px;border:1px solid var(--hair);border-radius:100px; + background:var(--white);color:var(--ink-soft); +} +.tag--signal{border-color:var(--signal);color:#B5691A;background:#FBF0E1} + +.hero__stamp{ + position:absolute;top:70px;right:34px; + width:112px;height:112px;border-radius:50%; + border:2px solid var(--signal);color:var(--signal); + display:grid;place-items:center;transform:rotate(-13deg); + font-family:"Bricolage Grotesque",sans-serif;opacity:.9; +} +.hero__stamp::before{ + content:"";position:absolute;inset:7px;border:1px dashed var(--signal);border-radius:50%;opacity:.6; +} +.hero__stamp span{font-weight:800;font-size:2.1rem;line-height:1} +.hero__stamp em{ + font-family:"IBM Plex Mono",monospace;font-style:normal; + font-size:.6rem;letter-spacing:.22em;text-transform:uppercase;margin-top:2px; +} + +/* ---------- REPORT / SECTIONS ---------- */ +.report{position:relative;z-index:1;max-width:var(--maxw);margin:0 auto;padding:0 28px} +.block{ + display:grid;grid-template-columns:300px 1fr;gap:44px; + padding:52px 0;border-top:1px solid var(--hair); +} +.block__rail{position:sticky;top:36px;align-self:start} +.block__num{ + font-family:"IBM Plex Mono",monospace;font-weight:600; + font-size:.9rem;letter-spacing:.1em;color:var(--aqua-deep);margin-bottom:16px; +} +.block__title{ + font-family:"Bricolage Grotesque",sans-serif;font-weight:700; + font-size:clamp(1.7rem,3.4vw,2.4rem);line-height:1.02;letter-spacing:-.02em; + margin:0;color:var(--ink); +} +.block__sub{color:var(--muted);font-size:.96rem;margin:14px 0 0;max-width:34ch} + +/* ---------- TOPICS (accordion) ---------- */ +.topics{list-style:none;margin:0;padding:0;border-top:1px solid var(--hair)} +.topic{border-bottom:1px solid var(--hair)} +.topic__head{ + width:100%;background:none;border:0;cursor:pointer;text-align:left; + display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:18px; + padding:22px 4px;font:inherit;color:var(--ink); + transition:padding-left .3s var(--ease); +} +.topic__head:hover{padding-left:10px} +.topic__idx{ + font-family:"IBM Plex Mono",monospace;font-size:.82rem;color:var(--muted); + min-width:2ch; +} +.topic__title{ + font-family:"Bricolage Grotesque",sans-serif;font-weight:500; + font-size:clamp(1.12rem,2vw,1.32rem);letter-spacing:-.01em;line-height:1.25; +} +.topic__mark{ + position:relative;width:34px;height:34px;flex:none;border-radius:50%; + border:1px solid var(--hair);display:grid;place-items:center; + transition:background .35s var(--ease),border-color .35s var(--ease),transform .35s var(--ease); +} +.topic__mark svg{width:17px;height:17px;fill:none;stroke:var(--ink-soft); + stroke-width:2;stroke-linecap:round;grid-area:1/1; + transition:opacity .3s var(--ease),transform .3s var(--ease)} +.topic__mark .ic-check{opacity:0;transform:scale(.5);stroke:var(--white)} +.topic__head:hover .topic__mark{border-color:var(--aqua)} + +/* open state */ +.topic.is-open .topic__mark{background:var(--aqua);border-color:var(--aqua);transform:rotate(90deg)} +.topic.is-open .ic-plus{opacity:0;transform:rotate(45deg) scale(.5)} +.topic.is-open .ic-check{opacity:1;transform:scale(1)} +.topic.is-open .topic__title{color:var(--aqua-deep)} + +.topic__panel{overflow:hidden} +.topic__inner{ + padding:2px 4px 30px 0;max-width:62ch; + display:flex;flex-direction:column;gap:20px; +} +.prose{color:var(--ink-soft);font-size:1.03rem} +.prose p{margin:0 0 .8em} +.prose p:last-child{margin-bottom:0} +.prose strong{color:var(--ink);font-weight:600} + +.topic__link{ + align-self:flex-start;display:inline-flex;align-items:center;gap:8px; + font-family:"IBM Plex Mono",monospace;font-size:.82rem;letter-spacing:.03em; + color:var(--aqua-deep);text-decoration:none; + padding:9px 16px;border:1px solid var(--aqua);border-radius:100px; + transition:background .25s var(--ease),color .25s var(--ease); +} +.topic__link svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} +.topic__link:hover{background:var(--aqua);color:var(--white)} + +.media{margin:0} +.media img,.media video{width:100%;border-radius:var(--radius);border:1px solid var(--hair);display:block} +.media--embed{position:relative;aspect-ratio:16/9} +.media--embed iframe{width:100%;height:100%;border:1px solid var(--hair);border-radius:var(--radius)} +.media figcaption{ + font-family:"IBM Plex Mono",monospace;font-size:.74rem;color:var(--muted); + margin-top:9px;letter-spacing:.02em; +} + +/* ---------- CTA ---------- */ +.cta{ + position:relative;z-index:1;background:var(--ink);color:var(--paper); + margin-top:40px; +} +.cta__inner{max-width:var(--maxw);margin:0 auto;padding:84px 28px 56px;text-align:center} +.cta__kicker{ + font-family:"IBM Plex Mono",monospace;font-size:.76rem;letter-spacing:.16em; + text-transform:uppercase;color:var(--aqua);margin:0 0 20px; +} +.cta__title{ + font-family:"Bricolage Grotesque",sans-serif;font-weight:700; + font-size:clamp(1.9rem,4.5vw,3rem);line-height:1.05;letter-spacing:-.02em; + margin:0 auto;max-width:18ch; +} +.cta__btn{ + display:inline-block;margin-top:34px;padding:16px 38px; + background:var(--aqua);color:#04201E;text-decoration:none;border-radius:100px; + font-family:"IBM Plex Mono",monospace;font-weight:600;font-size:.92rem;letter-spacing:.03em; + transition:transform .25s var(--ease),box-shadow .25s var(--ease); +} +.cta__btn:hover{transform:translateY(-2px);box-shadow:0 12px 30px -10px rgba(23,176,166,.6)} +.cta__legal{ + text-align:center;color:rgba(247,245,240,.45); + font-family:"IBM Plex Mono",monospace;font-size:.72rem;letter-spacing:.06em; + padding:0 28px 40px;margin:0; +} + +/* ---------- RESPONSIVE ---------- */ +@media (max-width:760px){ + .hero{padding:60px 22px 40px} + .hero__stamp{width:82px;height:82px;top:52px;right:20px} + .hero__stamp span{font-size:1.5rem} + .block{grid-template-columns:1fr;gap:22px;padding:40px 0} + .block__rail{position:static} + .report{padding:0 22px} +} + +/* ---------- A11Y ---------- */ +.topic__head:focus-visible{outline:2px solid var(--aqua);outline-offset:3px;border-radius:8px} +.cta__btn:focus-visible,.topic__link:focus-visible{outline:2px solid var(--aqua);outline-offset:3px} + +@media (prefers-reduced-motion:reduce){ + *{scroll-behavior:auto!important} + .topic__panel{transition:none!important} +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..2a7af5b --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,48 @@ +(function () { + "use strict"; + + var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + function collapse(panel) { + if (reduce) { panel.hidden = true; return; } + var h = panel.scrollHeight; + panel.style.height = h + "px"; + // force reflow so the transition runs + panel.offsetHeight; // eslint-disable-line no-unused-expressions + panel.style.height = "0px"; + panel.addEventListener("transitionend", function done() { + panel.hidden = true; + panel.style.height = ""; + panel.removeEventListener("transitionend", done); + }); + } + + function expand(panel) { + panel.hidden = false; + if (reduce) return; + var h = panel.scrollHeight; + panel.style.height = "0px"; + panel.offsetHeight; // reflow + panel.style.height = h + "px"; + panel.addEventListener("transitionend", function done() { + panel.style.height = ""; + panel.removeEventListener("transitionend", done); + }); + } + + document.querySelectorAll("[data-topic]").forEach(function (item) { + var head = item.querySelector(".topic__head"); + var panel = item.querySelector(".topic__panel"); + if (!head || !panel) return; + + // enable CSS height transition + panel.style.transition = "height .42s cubic-bezier(.22,.61,.36,1)"; + panel.style.overflow = "hidden"; + + head.addEventListener("click", function () { + var open = item.classList.toggle("is-open"); + head.setAttribute("aria-expanded", open ? "true" : "false"); + if (open) { expand(panel); } else { collapse(panel); } + }); + }); +})(); diff --git a/templates/admin/base.html b/templates/admin/base.html new file mode 100644 index 0000000..d7b2e63 --- /dev/null +++ b/templates/admin/base.html @@ -0,0 +1,41 @@ + + + + + + {% block title %}Admin{% endblock %} · JQC + + + + + + + {% if session.get('admin') %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..45508c9 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,64 @@ +{% extends "admin/base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} +
+
+

Content

+

Edit the sections and topics shown on the public site.

+
+ +
+ +{% if not sections %} +
No sections yet. Start by adding one.
+{% endif %} + +{% for section in sections %} +
+
+
+ §{{ '%02d' % section.num }} +

{{ section.title }}

+ {% if section.subtitle %}{{ section.subtitle }}{% endif %} +
+
+ Edit + + Topic +
+ + +
+
+
+ + {% if section.topics %} +
    + {% for topic in section.topics %} +
  • +
    + {{ topic.sort_order }} + {{ topic.title | safe }} + {% if topic.media_type and topic.media_type != 'none' %}{{ topic.media_type }}{% endif %} + {% if topic.link_url %}link{% endif %} +
    +
    + Edit +
    + + +
    +
    +
  • + {% endfor %} +
+ {% else %} +

No topics in this section yet.

+ {% endif %} +
+{% endfor %} +{% endblock %} diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..00ae23f --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,21 @@ +{% extends "admin/base.html" %} +{% block title %}Sign in{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block content %} + +{% endblock %} diff --git a/templates/admin/section_form.html b/templates/admin/section_form.html new file mode 100644 index 0000000..6b74484 --- /dev/null +++ b/templates/admin/section_form.html @@ -0,0 +1,49 @@ +{% extends "admin/base.html" %} +{% block title %}{{ 'Edit section' if section else 'New section' }}{% endblock %} + +{% macro val(field, default='') -%} + {%- if form is not none -%}{{ form.get(field, default) }} + {%- elif section is not none -%}{{ section[field] if section[field] is not none else default }} + {%- else -%}{{ default }}{%- endif -%} +{%- endmacro %} + +{% block content %} +
+
+

{{ 'Edit section' if section else 'New section' }}

+

{{ section.title if section else 'Sections group topics on the public page.' }}

+
+ ← Back +
+ +
+ + +
+ + +
+ + + + + +
+ Cancel + +
+
+{% endblock %} diff --git a/templates/admin/topic_form.html b/templates/admin/topic_form.html new file mode 100644 index 0000000..41b90e8 --- /dev/null +++ b/templates/admin/topic_form.html @@ -0,0 +1,101 @@ +{% extends "admin/base.html" %} +{% block title %}{{ 'Edit topic' if topic else 'New topic' }}{% endblock %} + +{% macro val(field, default='') -%} + {%- if form is not none -%}{{ form.get(field, default) }} + {%- elif topic is not none -%}{{ topic[field] if topic[field] is not none else default }} + {%- else -%}{{ default }}{%- endif -%} +{%- endmacro %} + +{% block content %} +{% set current_section = (form.get('section_id') if form else (topic.section_id if topic else request.args.get('section'))) %} +{% set current_media = (form.get('media_type') if form else (topic.media_type if topic else 'none')) %} + +
+
+

{{ 'Edit topic' if topic else 'New topic' }}

+

{{ topic.title if topic else 'Add a new topic to a section.' }}

+
+ ← Back +
+ +
+ + +
+ + +
+ + + + + + + +
+ Media +
+ + +
+ +

image → <img> · video → <video> · embed → <iframe> (use a YouTube /embed/ URL)

+
+ +
+ Link button (optional) +
+ + +
+
+ +
+ Cancel + +
+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..04ed23f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,110 @@ + + + + + + JQC — Janitorial Quality Control · LT Services + + + + + + + + + +
+
+
+ LT Services · Quality Control Program +
+

JQCJanitorial Quality Control

+

+ A closer look at what the JQC Inspector can do — a unified, paperless + program that keeps every party in the loop, on every visit. +

+
+ iPad & Web + Real-time + SLA alerts + No login QR access +
+
+ +
+ +
+ {% for section in sections %} +
+
+
§{{ '%02d' % section.num }}
+

{{ section.title }}

+ {% if section.subtitle %}

{{ section.subtitle }}

{% endif %} +
+ +
    + {% for topic in section.topics %} +
  • + + +
  • + {% endfor %} +
+
+ {% endfor %} +
+ +
+
+

Paperless · Maintenance-free · No subscription

+

LT would be glad to run a live demonstration.

+ Request a demo +
+ +
+ + + +