Jul 22 - Initial code

This commit is contained in:
2026-07-22 14:50:16 -04:00
commit ade7c5b33c
21 changed files with 1588 additions and 0 deletions
+20
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
venv/
__pycache__/
*.pyc
.env
instance/
*.db
static/img/
static/vid/
+181
View File
@@ -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='<p>New copy with <strong>bold</strong>.</p>'
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','<p>Content.</p>','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=<paste hash from command below>
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.
+17
View File
@@ -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;
+221
View File
@@ -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/<int:topic_id>", 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/<int:topic_id>/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/<int:section_id>", 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/<int:section_id>/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"))
+111
View File
@@ -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)
+52
View File
@@ -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"
+16
View File
@@ -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
+21
View File
@@ -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
+8
View File
@@ -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"
+5
View File
@@ -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
+100
View File
@@ -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',
'<p>Full checklists equipped with <strong>timestamped photos</strong>, ratings, and captured signatures — ensuring transparency and accountability on every visit.</p>',
NULL, NULL, 'none', NULL, NULL, 10),
((SELECT id FROM section WHERE num=1), 'real-time-issue-tracking', 'Real-Time Issue Tracking',
'<p>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.</p>',
NULL, NULL, 'none', NULL, NULL, 20),
((SELECT id FROM section WHERE num=1), 'seamless-history-access', 'Seamless History Access',
'<p>Inspectors review the full inspection history right on their device, making progress tracking efficient and straightforward.</p>',
NULL, NULL, 'none', NULL, NULL, 30),
((SELECT id FROM section WHERE num=1), 'scheduled-work-made-easy', 'Scheduled Work Made Easy',
'<p>See all scheduled work and get started with the facility and checklist already selected — no setup, just go.</p>',
NULL, NULL, 'none', NULL, NULL, 40),
((SELECT id FROM section WHERE num=1), 'status-updates-with-evidence', 'Status Updates with Evidence',
'<p>Update an issue''s status and attach photos of the fix, streamlining the resolution process end to end.</p>',
NULL, NULL, 'none', NULL, NULL, 50),
((SELECT id FROM section WHERE num=2), 'shared-inspector-access', 'Shared Inspector Access',
'<p>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 <strong>same access</strong> as an LT inspector.</p>',
NULL, NULL, 'none', NULL, NULL, 10),
((SELECT id FROM section WHERE num=2), 'director-visibility-sla-alerts', 'Director-Level Visibility &amp; SLA Alerts',
'<p>The customer contract administrator has every piece of information at their fingertips — the same as our director level — and receives <strong>Service Level Agreement (SLA) alerts</strong> automatically.</p>',
NULL, NULL, 'none', NULL, NULL, 20),
((SELECT id FROM section WHERE num=3), 'notifications-and-qr-access', 'Automatic Notifications &amp; QR Access',
'<p>Communication is effortless. The app automatically notifies everyone involved about new inspections, flags issues, and keeps directors informed of any high-risk SLA concerns.</p><p>A simple scan of a <strong>QR code</strong> lets you or your tenants review QC history and report issues directly from a mobile device — no account or login required.</p>',
NULL, NULL, 'none', NULL, NULL, 10),
((SELECT id FROM section WHERE num=4), 'unified-no-cost-platform', 'Unified, No-Cost Platform',
'<p>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.</p><p><strong>No subscription charge and no hidden fees.</strong></p>',
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);
+175
View File
@@ -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}
}
+219
View File
@@ -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}
}
+48
View File
@@ -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); }
});
});
})();
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin{% endblock %} · JQC</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
</head>
<body class="{% block body_class %}{% endblock %}">
{% if session.get('admin') %}
<nav class="anav">
<a class="anav__brand" href="{{ url_for('admin.dashboard') }}">JQC<span>admin</span></a>
<div class="anav__right">
<a class="anav__link" href="{{ url_for('index') }}" target="_blank" rel="noopener">View site ↗</a>
<span class="anav__user">{{ session.get('admin') }}</span>
<form method="post" action="{{ url_for('admin.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
</form>
</div>
</nav>
{% endif %}
<main class="awrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for cat, msg in messages %}
<div class="flash flash--{{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<header class="page-head">
<div>
<h1>Content</h1>
<p class="muted">Edit the sections and topics shown on the public site.</p>
</div>
<div class="page-head__actions">
<a class="btn btn--ghost" href="{{ url_for('admin.section_form') }}">+ Section</a>
<a class="btn btn--primary" href="{{ url_for('admin.topic_form') }}">+ Topic</a>
</div>
</header>
{% if not sections %}
<div class="empty">No sections yet. Start by adding one.</div>
{% endif %}
{% for section in sections %}
<section class="sec-card">
<div class="sec-card__head">
<div class="sec-card__meta">
<span class="pill">§{{ '%02d' % section.num }}</span>
<h2>{{ section.title }}</h2>
{% if section.subtitle %}<span class="muted">{{ section.subtitle }}</span>{% endif %}
</div>
<div class="sec-card__actions">
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.section_form', section_id=section.id) }}">Edit</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.topic_form') }}?section={{ section.id }}">+ Topic</a>
<form method="post" action="{{ url_for('admin.section_delete', section_id=section.id) }}"
onsubmit="return confirm('Delete section “{{ section.title }}” and ALL its topics?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--danger btn--sm" type="submit">Delete</button>
</form>
</div>
</div>
{% if section.topics %}
<ul class="topic-rows">
{% for topic in section.topics %}
<li class="topic-row">
<div class="topic-row__main">
<span class="topic-row__order">{{ topic.sort_order }}</span>
<a class="topic-row__title" href="{{ url_for('admin.topic_form', topic_id=topic.id) }}">{{ topic.title | safe }}</a>
{% if topic.media_type and topic.media_type != 'none' %}<span class="chip">{{ topic.media_type }}</span>{% endif %}
{% if topic.link_url %}<span class="chip chip--link">link</span>{% endif %}
</div>
<div class="topic-row__actions">
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.topic_form', topic_id=topic.id) }}">Edit</a>
<form method="post" action="{{ url_for('admin.topic_delete', topic_id=topic.id) }}"
onsubmit="return confirm('Delete topic “{{ topic.title }}”?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--danger btn--sm" type="submit">Delete</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted sec-card__empty">No topics in this section yet.</p>
{% endif %}
</section>
{% endfor %}
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "admin/base.html" %}
{% block title %}Sign in{% endblock %}
{% block body_class %}login-body{% endblock %}
{% block content %}
<div class="login-card">
<div class="login-brand">JQC<span>admin</span></div>
<p class="login-hint">Sign in to edit site content.</p>
<form method="post" action="{{ url_for('admin.login') }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="field">
<span>Username</span>
<input type="text" name="username" autocomplete="username" autofocus required>
</label>
<label class="field">
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button class="btn btn--primary" type="submit">Sign in</button>
</form>
</div>
{% endblock %}
+49
View File
@@ -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 %}
<header class="page-head">
<div>
<h1>{{ 'Edit section' if section else 'New section' }}</h1>
<p class="muted">{{ section.title if section else 'Sections group topics on the public page.' }}</p>
</div>
<a class="btn btn--ghost" href="{{ url_for('admin.dashboard') }}">← Back</a>
</header>
<form method="post" class="form-card stack"
action="{{ url_for('admin.section_form', section_id=section.id) if section else url_for('admin.section_form') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid-2">
<label class="field">
<span>Number * <small class="muted">(shown as §NN, must be unique)</small></span>
<input type="number" name="num" value="{{ val('num') }}" required>
</label>
<label class="field">
<span>Sort order <small class="muted">(defaults to number × 10)</small></span>
<input type="number" name="sort_order" value="{{ val('sort_order') }}" placeholder="auto">
</label>
</div>
<label class="field">
<span>Title *</span>
<input type="text" name="title" value="{{ val('title') }}" required>
</label>
<label class="field">
<span>Subtitle</span>
<input type="text" name="subtitle" value="{{ val('subtitle') }}">
</label>
<div class="form-actions">
<a class="btn btn--ghost" href="{{ url_for('admin.dashboard') }}">Cancel</a>
<button class="btn btn--primary" type="submit">Save section</button>
</div>
</form>
{% endblock %}
+101
View File
@@ -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')) %}
<header class="page-head">
<div>
<h1>{{ 'Edit topic' if topic else 'New topic' }}</h1>
<p class="muted">{{ topic.title if topic else 'Add a new topic to a section.' }}</p>
</div>
<a class="btn btn--ghost" href="{{ url_for('admin.dashboard') }}">← Back</a>
</header>
<form method="post" class="form-card stack"
action="{{ url_for('admin.topic_form', topic_id=topic.id) if topic else url_for('admin.topic_form') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid-2">
<label class="field">
<span>Section *</span>
<select name="section_id" required>
{% for s in sections %}
<option value="{{ s.id }}" {{ 'selected' if current_section and s.id|string == current_section|string }}>
§{{ '%02d' % s.num }} — {{ s.title }}
</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Sort order</span>
<input type="number" name="sort_order" value="{{ val('sort_order', '10') }}">
</label>
</div>
<label class="field">
<span>Title *</span>
<input type="text" name="title" value="{{ val('title') }}" required>
</label>
<label class="field">
<span>Slug <small class="muted">(optional — auto-generated from the title, must be unique)</small></span>
<input type="text" name="slug" value="{{ val('slug') }}" placeholder="auto">
</label>
<label class="field">
<span>Body <small class="muted">(HTML — use &lt;p&gt;, &lt;strong&gt;, &lt;ul&gt;&lt;li&gt;)</small></span>
<textarea name="body_html" rows="7">{{ val('body_html') }}</textarea>
</label>
<fieldset class="fieldset">
<legend>Media</legend>
<div class="grid-2">
<label class="field">
<span>Type</span>
<select name="media_type">
{% for mt in media_types %}
<option value="{{ mt }}" {{ 'selected' if mt == current_media }}>{{ mt }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Caption</span>
<input type="text" name="media_caption" value="{{ val('media_caption') }}">
</label>
</div>
<label class="field">
<span>Media URL</span>
<input type="text" name="media_url" value="{{ val('media_url') }}"
placeholder="/static/img/photo.jpg · /static/vid/demo.mp4 · https://www.youtube.com/embed/ID">
</label>
<p class="hint muted">image → &lt;img&gt; · video → &lt;video&gt; · embed → &lt;iframe&gt; (use a YouTube <code>/embed/</code> URL)</p>
</fieldset>
<fieldset class="fieldset">
<legend>Link button (optional)</legend>
<div class="grid-2">
<label class="field">
<span>Link URL</span>
<input type="text" name="link_url" value="{{ val('link_url') }}" placeholder="https://…">
</label>
<label class="field">
<span>Link label</span>
<input type="text" name="link_label" value="{{ val('link_label') }}" placeholder="Learn more">
</label>
</div>
</fieldset>
<div class="form-actions">
<a class="btn btn--ghost" href="{{ url_for('admin.dashboard') }}">Cancel</a>
<button class="btn btn--primary" type="submit">Save topic</button>
</div>
</form>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JQC — Janitorial Quality Control · LT Services</title>
<meta name="description" content="The features of LT Services' JQC quality control platform — inspections, real-time issue tracking, SLA alerts, and QR access.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="grain" aria-hidden="true"></div>
<header class="hero">
<div class="hero__inner">
<div class="hero__eyebrow">
<span class="dot"></span> LT Services · Quality Control Program
</div>
<h1 class="hero__title">JQC<span class="hero__title-sub">Janitorial Quality Control</span></h1>
<p class="hero__lede">
A closer look at what the JQC Inspector can do — a unified, paperless
program that keeps every party in the loop, on every visit.
</p>
<div class="hero__meta">
<span class="tag">iPad &amp; Web</span>
<span class="tag">Real-time</span>
<span class="tag tag--signal">SLA alerts</span>
<span class="tag">No login QR access</span>
</div>
</div>
<div class="hero__stamp" aria-hidden="true">
<span>QC</span><em>verified</em>
</div>
</header>
<main class="report">
{% for section in sections %}
<section class="block" id="section-{{ section.num }}">
<div class="block__rail">
<div class="block__num">§{{ '%02d' % section.num }}</div>
<h2 class="block__title">{{ section.title }}</h2>
{% if section.subtitle %}<p class="block__sub">{{ section.subtitle }}</p>{% endif %}
</div>
<ul class="topics" role="list">
{% for topic in section.topics %}
<li class="topic" data-topic>
<button class="topic__head" type="button"
aria-expanded="false"
aria-controls="body-{{ topic.slug }}"
id="head-{{ topic.slug }}">
<span class="topic__idx">{{ '%02d' % loop.index }}</span>
<span class="topic__title">{{ topic.title | safe }}</span>
<span class="topic__mark" aria-hidden="true">
<svg viewBox="0 0 24 24" class="ic-plus"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<svg viewBox="0 0 24 24" class="ic-check"><polyline points="4 12 10 18 20 6"/></svg>
</span>
</button>
<div class="topic__panel" id="body-{{ topic.slug }}"
role="region" aria-labelledby="head-{{ topic.slug }}" hidden>
<div class="topic__inner">
{% if topic.body_html %}<div class="prose">{{ topic.body }}</div>{% endif %}
{% if topic.media_type == 'image' and topic.media_url %}
<figure class="media">
<img src="{{ topic.media_url }}" alt="{{ topic.media_caption or topic.title }}" loading="lazy">
{% if topic.media_caption %}<figcaption>{{ topic.media_caption }}</figcaption>{% endif %}
</figure>
{% elif topic.media_type == 'video' and topic.media_url %}
<figure class="media">
<video src="{{ topic.media_url }}" controls preload="metadata"></video>
{% if topic.media_caption %}<figcaption>{{ topic.media_caption }}</figcaption>{% endif %}
</figure>
{% elif topic.media_type == 'embed' and topic.media_url %}
<figure class="media media--embed">
<iframe src="{{ topic.media_url }}" title="{{ topic.media_caption or topic.title }}"
loading="lazy" allowfullscreen></iframe>
{% if topic.media_caption %}<figcaption>{{ topic.media_caption }}</figcaption>{% endif %}
</figure>
{% endif %}
{% if topic.link_url %}
<a class="topic__link" href="{{ topic.link_url }}" target="_blank" rel="noopener">
{{ topic.link_label or 'Learn more' }}
<svg viewBox="0 0 24 24"><path d="M7 17 17 7M9 7h8v8"/></svg>
</a>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
</main>
<footer class="cta">
<div class="cta__inner">
<p class="cta__kicker">Paperless · Maintenance-free · No subscription</p>
<h2 class="cta__title">LT would be glad to run a live demonstration.</h2>
<a class="cta__btn" href="{{ demo_url }}">Request a demo</a>
</div>
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
</footer>
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
</body>
</html>