Add comprehensive comments to all source files and expand documentation

This commit is contained in:
Troll (Hermes Agent) 2026-07-31 22:23:40 +00:00
parent 9e532a1920
commit b4f0222bd5
16 changed files with 1230 additions and 552 deletions

View file

@ -1,3 +1,23 @@
# .env.example
# ============
#
# Copy this file to .env for local development, or paste the values into
# Portainer when deploying the stack.
#
# Required for production:
# APP_SECRET_KEY - long random string used by Flask for sessions
# ADMIN_PASSWORD - password for the /admin dashboard
# SMTP_PASS - password for the SMTP account
# PUBLIC_BASE_URL - public HTTPS URL customers will use (e.g. https://booth.example.com)
#
# Optional:
# BOOTH_NAME - name shown in emails
# HOST_PORT - host-side port mapping for docker-compose (default 127.0.0.1:8000)
# INTERNAL_PORT - port gunicorn binds inside the container (default 8000)
# PRICE_PER_VERSION - shown on the receipt page (default 10.00)
# CURRENCY - currency label (default CAD)
# ADMIN_ALERT_EMAIL - unused; dashboard is the operator queue
APP_SECRET_KEY=change-me-in-production
ADMIN_PASSWORD=change-me
SMTP_HOST=mailroot8.namespro.ca
@ -8,9 +28,9 @@ SMTP_FROM=ai@hallsworth.ca
ADMIN_ALERT_EMAIL=
PUBLIC_BASE_URL=http://127.0.0.1:5000
BOOTH_NAME=Trollgorithm Theme Songs
DATABASE=/app/data/booth.db
UPLOAD_FOLDER=/app/uploads
INTERNAL_PORT=8000
HOST_PORT=127.0.0.1:8000
PRICE_PER_VERSION=10.00
CURRENCY=CAD
DATABASE=/app/data/booth.db
UPLOAD_FOLDER=/app/uploads

View file

@ -1,24 +1,42 @@
# Dockerfile
# ==========
#
# Builds the Theme Song Booth Flask app into a small production container.
#
# Steps:
# 1. Use Python 3.12 slim base image.
# 2. Install ffmpeg (used only if we later process audio metadata; harmless otherwise).
# 3. Install Python dependencies from requirements.txt.
# 4. Copy the entire repo into /app.
# 5. Create an unprivileged user (boothuser) and data/upload directories.
# 6. Expose the default internal port and run gunicorn on $INTERNAL_PORT.
FROM python:3.12-slim
WORKDIR /app
# Install ffmpeg; clean apt cache to keep image small.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Install Python requirements first for layer caching.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application source, templates, static files, etc.
COPY . .
# Avoid writing .pyc files and ensure stdout is unbuffered.
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Create a non-root user, persistent directories, and fix ownership.
RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app
USER boothuser
# Default internal port; override with INTERNAL_PORT env var
# Default internal port; override with INTERNAL_PORT env var.
EXPOSE 8000
# Use shell form so environment variables are expanded at runtime
# Use shell form so environment variables are expanded at runtime.
CMD gunicorn -b 0.0.0.0:${INTERNAL_PORT:-8000} --access-logfile - app:app

126
README.md
View file

@ -1,18 +1,54 @@
# Theme Song Booth
Prototype web app for a convention booth where attendees request custom AI-generated theme songs.
Custom theme-song request and delivery system for a convention booth. Customers fill out a form, the operator generates two AI-made song versions, the customer picks one, and the approved MP3 is delivered by email after payment is collected.
## Flow
## What this project does
1. Customer fills out the public request form at `/request`.
2. Operator generates a Suno Custom Mode prompt via Hermes and saves it in the admin detail page.
3. Operator generates two song versions in Suno and uploads the MP3s in admin.
4. Operator clicks **Send Preview Link**. Customer receives an email with a private player page.
5. Customer listens to Version A and Version B, then approves one/both or requests changes.
6. Operator sees the approval alert, collects payment via Square reader, then clicks **Mark Paid & Deliver**.
7. Customer receives the approved MP3(s) as email attachments.
- **Customer request page** (`/request`) — booth visitors enter their name, email, hobbies, notable facts, preferred genre, and extra requests. A branded banner image is shown.
- **Operator dashboard** (`/admin`) — queue of all requests with status filters, per-request detail page, and system reset.
- **Prompt generation** — the admin page builds a plain-text prompt for Hermes/AI, which returns a Title, Style, and Lyrics block. The operator pastes that response, clicks **Extract**, then uses Copy buttons to paste into Suno Custom Mode.
- **Song upload** — operator uploads Version A and Version B MP3s.
- **Customer player page** — a private `/play/<token>` page emails to the customer. They can listen to both versions, choose A/B/both, or request changes.
- **Payment and delivery** — operator enters a Square payment reference and clicks **Mark Paid & Deliver**. The approved MP3(s) are emailed as attachments.
- **System reset** — one button in the admin topbar clears all requests and files at the start of an event.
## Local Development
## Status flow
```
pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered
```
| Status | Meaning |
|---|---|
| `pending` | Customer submitted request; no prompt yet. |
| `prompt_ready` | Operator saved Title/Style/Lyrics. |
| `songs_uploaded` | Both MP3s uploaded; preview link can be sent. |
| `awaiting_payment` | Customer approved a version. |
| `paid` | Payment reference recorded; delivery email sent. |
| `delivered` | MP3 attachments emailed. |
## File layout
| File | Purpose |
|---|---|
| `app.py` | Flask routes, helpers, and email logic. |
| `config.py` | Environment-variable based configuration. |
| `models.py` | SQLite schema and database helper functions. |
| `init_db.py` | Standalone script to create the database tables. |
| `templates/request.html` | Customer request form (with banner). |
| `templates/thanks.html` | Post-submission confirmation. |
| `templates/player.html` | Customer audio player and approval page. |
| `templates/admin/login.html` | Admin password login. |
| `templates/admin/dashboard.html` | Operator queue with filters and reset. |
| `templates/admin/request.html` | Single-request detail / prompt / upload / delivery. |
| `static/Trollgorithm_booth.jpg` | Banner image on the request page. |
| `Dockerfile` | Production container image. |
| `docker-compose.yml` | Portainer stack definition. |
| `requirements.txt` | Python dependencies. |
| `.env.example` | Template for environment variables. |
| `REVIEW.md` | Quick reference for returning to this project. |
## Local development
```bash
cd /home/jess/workspace/theme-song-booth
@ -24,42 +60,62 @@ cp .env.example .env
.venv/bin/python -m flask --app app run --host=0.0.0.0
```
Visit:
- Customer form: http://127.0.0.1:5000/request
- Admin login: http://127.0.0.1:5000/admin
## Deployment with Portainer
The repo includes a `docker-compose.yml` that builds directly from GitLab, so Portainer can pull and deploy it as a stack.
1. Log in to your Portainer instance.
1. Log in to Portainer.
2. Go to **Stacks****Add stack**.
3. Choose **Repository** and paste:
3. Choose **Repository**:
- URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git`
- Branch: `main`
- Compose path: `docker-compose.yml`
4. Add environment variables directly in Portainer under **Environment variables**:
- `APP_SECRET_KEY` — long random string
- `ADMIN_PASSWORD` — password for `/admin`
- `SMTP_PASS``b3ZzD@eM!MkqVS8P`
- `PUBLIC_BASE_URL` — your HTTPS domain
- `HOST_PORT` — default `127.0.0.1:8000`
- `BOOTH_NAME` — optional
4. Add environment variables:
| Variable | Required | Purpose |
|---|---|---|
| `APP_SECRET_KEY` | Yes | Long random string for Flask sessions. Generate with `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
| `ADMIN_PASSWORD` | Yes | Password for `/admin`. |
| `SMTP_PASS` | Yes | Password for `ai@hallsworth.ca`. |
| `PUBLIC_BASE_URL` | Yes | Public HTTPS URL, e.g. `https://booth.dionysismedia.ca`. |
| `HOST_PORT` | No | Host-side port mapping, default `127.0.0.1:8000`. |
| `INTERNAL_PORT` | No | Port gunicorn binds inside container, default `8000`. |
| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. |
| `PRICE_PER_VERSION` | No | Shown on receipt page, default `10.00`. |
| `CURRENCY` | No | Currency label, default `CAD`. |
5. Deploy the stack.
6. Open a console into the `booth` container and run once:
6. Open a console in the `booth` container and run once:
```bash
python init_db.py
```
7. Point your reverse proxy at the `HOST_PORT` you chose (e.g. `http://host-ip:8000`).
8. Print the booth QR code pointing to `https://your-domain/request`.
### Portainer Notes
7. Point your reverse proxy at the `HOST_PORT` you chose.
8. Print or display a QR code pointing to `https://your-domain/request`.
- The `docker-compose.yml` uses named volumes (`booth-data`, `booth-uploads`) so Portainer handles persistence automatically.
- For a pre-built image instead of repo build, replace the `build:` block with an `image:` line pointing to your registry.
- Update the stack after each push to redeploy the latest code.
### Updating the deployment
## Files
After each push to GitLab, go to Portainer → **Stacks**`theme-song-booth`**Pull and redeploy** to rebuild from the repo.
- `app.py` — Flask application with public/admin routes and email logic.
- `models.py` — SQLite schema and helper functions.
- `config.py` — Configuration loaded from environment.
- `templates/` — Jinja2 HTML templates.
- `init_db.py` — Standalone script to create the SQLite database.
- `Dockerfile` / `docker-compose.yml` — Container packaging for Portainer.
## Important notes
- **No `.env` file in production.** `docker-compose.yml` passes variables directly from Portainer. This avoids Portainer's `env_file not found` error.
- **Payments are manual.** The app records a Square payment reference but does not integrate with Square's API. Use a Square Terminal/Reader at the booth.
- **Operator queue is the dashboard.** No operator email alerts are sent; approvals and revision notes appear as status changes in `/admin`.
- **Security:** the repo is public on GitLab. No secrets are committed. Admin password is plain text in the Portainer environment.
## Common troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| "Send Preview Link" does nothing | Form tags were unbalanced (now fixed). | Redeploy the latest commit. |
| Emails not arriving | SMTP_PASS wrong or messages in spam. | Verify SMTP credentials; check spam folder. |
| Can't reach app through domain | Reverse proxy points to wrong host port. | Match `HOST_PORT` to your proxy upstream. |
| Static banner not showing | Browser cached old image. | Hard-refresh or redeploy stack. |
## License / ownership
Built for Jess's Trollgorithm theme-song booth. All code and assets are private to that project.

94
REVIEW.md Normal file
View file

@ -0,0 +1,94 @@
# REVIEW.md — Theme Song Booth
Quick reference for future work on this project.
## One-sentence summary
Flask app that lets convention attendees request custom AI-generated theme songs, lets an operator manage the queue, and emails MP3s after payment.
## Stack
- Python 3.12 + Flask
- SQLite (file-based, request-scoped connection via `g`)
- Gunicorn in Docker
- Portainer stack deployed from GitLab repo
- SMTP (SSL port 465) for customer emails
- Square Terminal/Reader for manual payment
## Repository
- GitLab: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth` (public)
- Deployed at: `https://booth.dionysismedia.ca`
## Key files and what they hold
| File | Notes |
|---|---|
| `app.py` | All routes, helpers, email function, status labels. |
| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. |
| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. |
| `init_db.py` | Run once after deploy: `python init_db.py`. |
| `templates/admin/request.html` | Biggest template; prompt extraction JS lives here. |
| `templates/admin/dashboard.html` | Queue table + topbar Reset System button. |
| `docker-compose.yml` | No `env_file`; variables come from Portainer. |
## Status meanings
```
pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered
```
## Operator workflow
1. Customer fills `/request`.
2. Open `/admin`, click request row.
3. Click **Copy customer info for Hermes**, paste result to Hermes.
4. Paste Hermes response (Title/Style/Lyrics format), click **Extract**, click **Save Prompt**.
5. Copy Style/Lyrics into Suno Custom Mode, generate two versions.
6. Upload Version A and B MP3s.
7. Click **Send Preview Link**.
8. Customer receives email, visits player, picks version.
9. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**.
10. Customer receives MP3 attachment(s) by email.
## Environment variables that matter
```
APP_SECRET_KEY
ADMIN_PASSWORD
SMTP_PASS
PUBLIC_BASE_URL
BOOTH_NAME
HOST_PORT
INTERNAL_PORT
```
## Gotchas
- Multiple forms on `admin/request.html` must stay properly closed; nested forms break buttons.
- `upload_songs` form needs `enctype="multipart/form-data"` and a matching `</form>`.
- The dashboard uses `basename()` as a function, not a Jinja filter.
- Reset System deletes DB rows **and** all files under `UPLOAD_FOLDER`.
## Things that could be improved later
- Move customer info copy/paste to a direct Hermes API/webhook call.
- Add operator email alerts as an opt-in config instead of hard-disabled.
- Store admin password hashed.
- Add a receipt/pricing page for the customer.
- Upload progress indicator for large MP3s.
- Back up SQLite and uploads to S3 or similar before reset.
## How to redeploy
1. Push changes to GitLab `main`.
2. In Portainer: Stacks → `theme-song-booth` → Pull and redeploy.
3. If schema changed, open container console and run `python init_db.py`.
## Last major changes
- Added banner image and styling to request page.
- Moved Reset System button to topbar next to Log out.
- Added file/email status badges on admin request page.
- Added per-request Delete and full-system Reset.
- Switched Hermes prompt workflow to plain-text Title/Style/Lyrics blocks.

151
app.py
View file

@ -1,3 +1,30 @@
"""
app.py
======
Main Flask application for the Theme Song Booth.
This module defines all HTTP routes, helper functions, and the email layer.
It is meant to be served by gunicorn inside a Docker container (see Dockerfile).
Public routes (customers):
- / -> redirects to /request
- /request -> customer submits their info
- /thanks/<id> -> confirmation page after submission
- /play/<token> -> private player page with Version A and B
- /play/<token>/approve -> customer picks a version
- /play/<token>/revise -> customer asks for changes
- /audio/<token>/<v>.mp3 -> serves the uploaded MP3 files
Admin routes:
- /admin/login -> password login
- /admin/logout -> clears session
- /admin -> dashboard queue
- /admin/request/<id> -> detail/edit page for a single request
- /admin/request/<id>/delete -> deletes one request and its files
- /admin/reset -> deletes ALL requests and ALL files
"""
# Standard library imports
import os
import shutil
import smtplib
@ -5,16 +32,26 @@ import ssl
from email.message import EmailMessage
from pathlib import Path
# Flask and related imports
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app
from werkzeug.utils import secure_filename
# Project imports
from config import Config
from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
# Create the Flask app and load configuration from Config class.
app = Flask(__name__)
app.config.from_object(Config)
# Ensure the SQLite connection is closed at the end of each request.
app.teardown_appcontext(close_db)
# Human-readable labels for each status value stored in the database.
STATUS_LABELS = {
'pending': 'Pending',
'prompt_ready': 'Prompt Ready',
@ -24,27 +61,46 @@ STATUS_LABELS = {
'delivered': 'Delivered',
}
# ---------------- helpers ----------------
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def is_admin():
"""Return True if the current browser session is logged in as admin."""
return session.get('admin') is True
def require_admin():
"""Redirect to the admin login page if the user is not logged in."""
if not is_admin():
return redirect(url_for('admin_login'))
def admin_password_ok(pw):
"""Check the submitted admin password against the configured one."""
return pw and pw == current_app.config['ADMIN_PASSWORD']
def allowed_file(filename):
"""Return True if the uploaded filename has an allowed extension (mp3)."""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
def upload_path(request_id):
"""Return the per-request upload directory path, creating it if necessary."""
p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id)
p.mkdir(parents=True, exist_ok=True)
return p
def save_upload(request_id, file_obj, version):
"""
Save an uploaded MP3 file for a request.
:param request_id: database ID of the request
:param file_obj: Flask FileStorage from request.files
:param version: 'a' or 'b'
:return: full filesystem path saved, or None on missing/invalid file
"""
if not file_obj or file_obj.filename == '':
return None
if not allowed_file(file_obj.filename):
@ -55,7 +111,15 @@ def save_upload(request_id, file_obj, version):
file_obj.save(p / filename)
return str(p / filename)
def send_email(to, subject, body, attachments=None):
"""
Send an email via SMTP_SSL.
:param to: recipient address
:param subject: email subject
:param body: plain-text body
:param attachments: optional list of (filepath, attachment_name) tuples
"""
cfg = current_app.config
if not cfg['SMTP_PASS']:
raise RuntimeError('SMTP_PASS is not configured')
@ -66,6 +130,7 @@ def send_email(to, subject, body, attachments=None):
msg['Subject'] = subject
msg.set_content(body)
# Attach any MP3 files as audio/mpeg attachments.
if attachments:
for path, name in attachments:
with open(path, 'rb') as f:
@ -76,14 +141,24 @@ def send_email(to, subject, body, attachments=None):
server.login(cfg['SMTP_USER'], cfg['SMTP_PASS'])
server.send_message(msg)
# ---------------- public ----------------
# ---------------------------------------------------------------------------
# Public customer routes
# ---------------------------------------------------------------------------
@app.route('/')
def index():
"""Root route: redirect customers straight to the request form."""
return redirect(url_for('request_form'))
@app.route('/request', methods=['GET', 'POST'])
def request_form():
"""
Public request form.
GET -> shows the form with the banner image.
POST -> creates a database record and redirects to the thanks page.
"""
if request.method == 'POST':
rid = create_request(
name=request.form.get('name', '').strip(),
@ -97,22 +172,34 @@ def request_form():
return redirect(url_for('thanks', rid=rid))
return render_template('request.html')
@app.route('/thanks/<int:rid>')
def thanks(rid):
"""Confirmation page shown after a customer submits a request."""
req = get_request_by_id(rid)
if not req:
abort(404)
return render_template('thanks.html', req=req)
@app.route('/play/<token>')
def play(token):
"""
Private player page for a customer.
The token is a cryptographically random URL-safe string generated at request time.
"""
req = get_request_by_token(token)
if not req:
abort(404)
return render_template('player.html', req=req)
@app.route('/play/<token>/approve', methods=['POST'])
def approve(token):
"""
Customer has chosen Version A, Version B, or both.
Updates the request status to 'awaiting_payment' so the operator can collect payment.
"""
req = get_request_by_token(token)
if not req:
abort(404)
@ -123,28 +210,37 @@ def approve(token):
update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc())
# alert operator (disabled — admin dashboard is the queue)
# NOTE: Operator email alerts are intentionally disabled. The admin dashboard is the single queue.
# alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
# if alert_to: ...
flash('Thanks! Please return to the booth to finalize payment.', 'success')
return redirect(url_for('play', token=token))
@app.route('/play/<token>/revise', methods=['POST'])
def revise(token):
"""
Customer asked for changes. Store the note and reset status to 'songs_uploaded'
so the operator sees it in the dashboard queue.
"""
req = get_request_by_token(token)
if not req:
abort(404)
note = request.form.get('revision_note', '').strip()
update_request(req['id'], revision_note=note, status='songs_uploaded')
# Revision feedback is stored in the DB and surfaced on the admin dashboard.
# No operator email is sent — the dashboard is the single queue.
# NOTE: No operator email is sent; the dashboard is the single queue.
flash('Your feedback has been saved. We will regenerate and update you.', 'success')
return redirect(url_for('play', token=token))
@app.route('/audio/<token>/<version>.mp3')
def audio(token, version):
"""
Serve an uploaded MP3 file for a specific request token and version ('a' or 'b').
This keeps the files off the public static path and ties them to the private token.
"""
req = get_request_by_token(token)
if not req:
abort(404)
@ -156,10 +252,14 @@ def audio(token, version):
abort(404)
return send_from_directory(Path(path).parent, Path(path).name)
# ---------------- admin ----------------
# ---------------------------------------------------------------------------
# Admin routes
# ---------------------------------------------------------------------------
@app.route('/admin/login', methods=['GET', 'POST'])
def admin_login():
"""Simple session-based admin login. Password is set via ADMIN_PASSWORD env var."""
if is_admin():
return redirect(url_for('admin_dashboard'))
if request.method == 'POST':
@ -169,13 +269,20 @@ def admin_login():
flash('Invalid password.', 'error')
return render_template('admin/login.html')
@app.route('/admin/logout')
def admin_logout():
"""Clear the admin session."""
session.pop('admin', None)
return redirect(url_for('admin_login'))
@app.route('/admin')
def admin_dashboard():
"""
Main operator queue.
Optional ?status= filter lets operators focus on one state at a time.
"""
redir = require_admin()
if redir:
return redir
@ -183,8 +290,15 @@ def admin_dashboard():
requests = list_requests(status_filter)
return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=status_filter)
@app.route('/admin/request/<int:rid>', methods=['GET', 'POST'])
def admin_request(rid):
"""
Detail/edit page for a single request.
GET -> render the request details and editing forms.
POST -> handle one of four actions:
save_prompt, upload_songs, notify_customer, mark_paid_deliver
"""
redir = require_admin()
if redir:
return redir
@ -192,6 +306,7 @@ def admin_request(rid):
if not req:
abort(404)
# Small helpers exposed to the template for status badges.
def file_exists(path):
return bool(path and Path(path).exists())
@ -202,6 +317,7 @@ def admin_request(rid):
action = request.form.get('action')
if action == 'save_prompt':
# Store the generated title/style/lyrics and mark prompt ready.
update_request(rid,
suno_title=request.form.get('suno_title', '').strip(),
suno_style=request.form.get('suno_style', '').strip(),
@ -211,6 +327,7 @@ def admin_request(rid):
flash('Prompt saved.', 'success')
elif action == 'upload_songs':
# Save uploaded MP3 files for Version A and/or Version B.
a_path = save_upload(rid, request.files.get('song_a'), 'a')
b_path = save_upload(rid, request.files.get('song_b'), 'b')
fields = {}
@ -224,6 +341,7 @@ def admin_request(rid):
flash('Songs uploaded.', 'success')
elif action == 'notify_customer':
# Email the customer a private player link. Both songs must be uploaded first.
if not (req['song_a_path'] and req['song_b_path']):
flash('Both songs must be uploaded first.', 'error')
else:
@ -237,6 +355,7 @@ def admin_request(rid):
flash(f'Failed to send preview email: {e}', 'error')
elif action == 'mark_paid_deliver':
# Finalize: record Square payment ref, attach approved MP3s, email customer.
if req['customer_approved'] == 'none':
flash('Customer has not approved a version yet.', 'error')
else:
@ -264,8 +383,10 @@ def admin_request(rid):
return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename)
@app.route('/admin/request/<int:rid>/delete', methods=['POST'])
def admin_delete_request(rid):
"""Delete a single request and remove its uploaded MP3 files."""
redir = require_admin()
if redir:
return redir
@ -273,7 +394,7 @@ def admin_delete_request(rid):
if not req:
abort(404)
# Delete uploaded files if they exist
# Delete uploaded files if they exist.
for field in ('song_a_path', 'song_b_path'):
path = req.get(field)
if path and Path(path).exists():
@ -281,7 +402,7 @@ def admin_delete_request(rid):
Path(path).unlink()
except OSError:
pass
# Remove empty upload directory
# Remove empty upload directory.
upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid)
if upload_dir.exists():
try:
@ -293,8 +414,14 @@ def admin_delete_request(rid):
flash(f'Request #{rid} deleted.', 'success')
return redirect(url_for('admin_dashboard'))
@app.route('/admin/reset', methods=['POST'])
def admin_reset_system():
"""
Nuclear reset for the start of an event.
Deletes all database rows and all files/directories under UPLOAD_FOLDER.
Requires clicking through a browser confirm dialog.
"""
redir = require_admin()
if redir:
return redir
@ -314,12 +441,18 @@ def admin_reset_system():
flash('System reset complete. All orders and files have been cleared.', 'success')
return redirect(url_for('admin_dashboard'))
# ---------------- init ----------------
# ---------------------------------------------------------------------------
# CLI and entry point
# ---------------------------------------------------------------------------
@app.cli.command('init-db')
def init_db_command():
"""Flask CLI command: flask --app app init-db"""
init_db()
print('Database initialized.')
if __name__ == '__main__':
# Development-only entry point. Production uses gunicorn (see Dockerfile).
app.run(debug=True, host='0.0.0.0')

View file

@ -1,29 +1,61 @@
"""
config.py
=========
Configuration object loaded by Flask from environment variables.
The application expects values to be provided via Portainer environment
variables or a local .env file during development.
All values have sensible defaults where safe, but the following MUST be
set in production:
- APP_SECRET_KEY
- ADMIN_PASSWORD
- SMTP_PASS
- PUBLIC_BASE_URL
"""
import os
from dotenv import load_dotenv
# Load variables from .env file if present (development mode).
load_dotenv()
class Config:
# Flask secret key: used to sign session cookies. Must be a long random string in production.
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me')
# SQLite database path inside the container.
DATABASE = os.environ.get('DATABASE', '/app/data/booth.db')
# Directory where uploaded MP3 files are stored inside the container.
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
# Only MP3 uploads are allowed.
ALLOWED_EXTENSIONS = {'mp3'}
# SMTP server settings for sending customer emails.
SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca')
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))
SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca')
SMTP_PASS = os.environ.get('SMTP_PASS', '')
SMTP_FROM = os.environ.get('SMTP_FROM', 'ai@hallsworth.ca')
# Admin login password (plain text, set via env).
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '')
# Optional operator alert email. Currently unused because the dashboard is the queue.
ADMIN_ALERT_EMAIL = os.environ.get('ADMIN_ALERT_EMAIL', '')
# Public HTTPS URL used in customer emails and QR codes.
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000')
# Booth name used in email sign-offs.
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')
# Port the container listens on internally (gunicorn)
# Internal port gunicorn listens on inside the container.
INTERNAL_PORT = int(os.environ.get('INTERNAL_PORT', '8000'))
# Price settings (informational, for receipt page)
# Price per version shown on the receipt page (informational only; payment is manual).
PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00'))
CURRENCY = os.environ.get('CURRENCY', 'CAD')

View file

@ -1,3 +1,16 @@
# docker-compose.yml
# ==================
#
# Portainer stack definition.
# Builds the image directly from the GitLab repository (main branch).
#
# Environment variables are set in Portainer under the stack's
# Environment variables section. The container uses them directly,
# so no .env file is required on disk.
#
# Named volumes keep the SQLite database and uploaded MP3s persistent
# across container restarts and redeploys.
services:
booth:
build:
@ -12,10 +25,13 @@ services:
- SMTP_USER=${SMTP_USER:-ai@hallsworth.ca}
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM:-ai@hallsworth.ca}
- ADMIN_ALERT_EMAIL=${ADMIN_ALERT_EMAIL:-}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL}
- BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs}
- INTERNAL_PORT=${INTERNAL_PORT:-8000}
- PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00}
- CURRENCY=${CURRENCY:-CAD}
- DATABASE=${DATABASE:-/app/data/booth.db}
- UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads}
ports:
- "${HOST_PORT:-127.0.0.1:8000}:${INTERNAL_PORT:-8000}"
volumes:

View file

@ -1,12 +1,25 @@
"""
init_db.py
==========
Standalone script to create the SQLite database tables.
Run this once inside the container after deployment:
python init_db.py
Or use the Flask CLI command:
flask --app app init-db
"""
import os
import sys
# Ensure project root is importable
# Ensure the project root is on sys.path so `from app import app` works.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import app
from models import init_db
# Use the configured database path and create tables.
with app.app_context():
init_db()
print(f"Database initialized at {app.config['DATABASE']}")

View file

@ -1,8 +1,23 @@
"""
models.py
=========
SQLite database layer for the Theme Song Booth.
This module defines the schema and all database operations. Flask's
application context (`g`) is used to manage one connection per request.
Schema overview (see SCHEMA constant):
- requests table stores customer data, generated prompts, file paths,
approval state, email timestamps, payment reference, and player token.
- Indexes on status and player_token for fast queue/lookup.
"""
import sqlite3
import secrets
from datetime import datetime, timezone
from flask import current_app, g
# SQL executed by init_db() to create the requests table and indexes.
SCHEMA = """
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -33,30 +48,45 @@ CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token);
"""
def get_db():
"""Get or create a SQLite connection tied to the current Flask request context."""
if 'db' not in g:
g.db = sqlite3.connect(current_app.config['DATABASE'])
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
"""Close the request-scoped SQLite connection. Registered as teardown handler."""
db = g.pop('db', None)
if db is not None:
db.close()
def init_db():
"""Create the database file and tables. Safe to run multiple times."""
db = sqlite3.connect(current_app.config['DATABASE'])
db.executescript(SCHEMA)
db.commit()
db.close()
def new_token():
"""Generate a URL-safe random token used for private player links."""
return secrets.token_urlsafe(32)
def now_utc():
"""Return current UTC time as ISO-8601 string for timestamp columns."""
return datetime.now(timezone.utc).isoformat()
def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests):
"""
Insert a new customer request.
Returns the auto-generated request id.
"""
db = get_db()
cur = db.execute(
"""INSERT INTO requests
@ -67,17 +97,23 @@ def create_request(name, email, hobbies, notable_facts, style_genre, extra_reque
db.commit()
return cur.lastrowid
def get_request_by_id(request_id):
"""Fetch one request by numeric id. Returns dict or None."""
db = get_db()
row = db.execute('SELECT * FROM requests WHERE id = ?', (request_id,)).fetchone()
return dict(row) if row else None
def get_request_by_token(token):
"""Fetch one request by its private player token. Returns dict or None."""
db = get_db()
row = db.execute('SELECT * FROM requests WHERE player_token = ?', (token,)).fetchone()
return dict(row) if row else None
def list_requests(status=None):
"""List all requests, optionally filtered by status, newest first."""
db = get_db()
if status:
rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall()
@ -85,7 +121,12 @@ def list_requests(status=None):
rows = db.execute('SELECT * FROM requests ORDER BY created_at DESC').fetchall()
return [dict(r) for r in rows]
def update_request(request_id, **fields):
"""
Update arbitrary columns for a request.
Example: update_request(1, status='prompt_ready', suno_style='...')
"""
if not fields:
return
db = get_db()
@ -94,12 +135,16 @@ def update_request(request_id, **fields):
db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals)
db.commit()
def delete_request(request_id):
"""Delete a single request by id. Does NOT delete associated files."""
db = get_db()
db.execute('DELETE FROM requests WHERE id = ?', (request_id,))
db.commit()
def reset_all_requests():
"""Delete every row in the requests table. Does NOT delete files."""
db = get_db()
db.execute('DELETE FROM requests')
db.commit()

View file

@ -1,3 +1,13 @@
# requirements.txt
# ================
#
# Python dependencies for the Theme Song Booth Flask app.
#
# flask - web framework
# gunicorn - production WSGI server used by Dockerfile
# python-dotenv - loads .env files in development
# werkzeug - utilities for file uploads and password hashing
flask
gunicorn
python-dotenv

View file

@ -5,26 +5,82 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard</title>
<style>
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
.container{max-width:1100px;margin:0 auto}
h1{color:#60a5fa}
.filters{margin-bottom:1rem}
.filters a{color:#93c5fd;text-decoration:none;margin-right:1rem}
.filters a.active{font-weight:bold;color:#fff}
table{width:100%;border-collapse:collapse;background:#1f2937;border-radius:.5rem;overflow:hidden}
th,td{padding:.7rem;text-align:left;border-bottom:1px solid #374151}
th{background:#111827;color:#9ca3af}
tr:hover{background:#2d3748}
.status-badge{display:inline-block;padding:.25rem .6rem;border-radius:9999px;font-size:.8rem;font-weight:600;background:#374151}
.awaiting_payment{background:#f59e0b;color:#000}
.paid,.delivered{background:#10b981;color:#000}
.pending,.prompt_ready{background:#60a5fa;color:#000}
.songs_uploaded{background:#a78bfa;color:#000}
.actions a{color:#93c5fd;text-decoration:none;margin-right:.8rem}
.actions button.delete{background:transparent;color:#f87171;border:none;padding:0;cursor:pointer;font:inherit;text-decoration:none;margin-right:.8rem}
.logout{float:right;color:#f87171;text-decoration:none}
.topbar{float:right;display:flex;gap:.75rem;align-items:center}
.topbar form{display:inline}
/*
Operator dashboard queue.
Shows all requests in a table, with status filters,
per-row Open/Delete actions, and a topbar Reset System button.
*/
body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
background:#111827;
color:#f3f4f6;
margin:0;
padding:1rem;
line-height:1.5;
}
.container{max-width:1100px;margin:0 auto;}
h1{color:#60a5fa;}
.filters{margin-bottom:1rem;}
.filters a{
color:#93c5fd;
text-decoration:none;
margin-right:1rem;
}
.filters a.active{font-weight:bold;color:#fff;}
table{
width:100%;
border-collapse:collapse;
background:#1f2937;
border-radius:.5rem;
overflow:hidden;
}
th,td{padding:.7rem;text-align:left;border-bottom:1px solid #374151;}
th{background:#111827;color:#9ca3af;}
tr:hover{background:#2d3748;}
.status-badge{
display:inline-block;
padding:.25rem .6rem;
border-radius:9999px;
font-size:.8rem;
font-weight:600;
background:#374151;
}
.awaiting_payment{background:#f59e0b;color:#000;}
.paid,.delivered{background:#10b981;color:#000;}
.pending,.prompt_ready{background:#60a5fa;color:#000;}
.songs_uploaded{background:#a78bfa;color:#000;}
.actions a{
color:#93c5fd;
text-decoration:none;
margin-right:.8rem;
}
.actions button.delete{
background:transparent;
color:#f87171;
border:none;
padding:0;
cursor:pointer;
font:inherit;
text-decoration:none;
margin-right:.8rem;
}
.logout{float:right;color:#f87171;text-decoration:none;}
.flash{
padding:.8rem;
background:#064e3b;
border-radius:.5rem;
margin-bottom:1rem;
}
.flash.error{background:#450a0a;}
/* Topbar with Reset System button and Log out link */
.topbar{
float:right;
display:flex;
gap:.75rem;
align-items:center;
}
.topbar form{display:inline;}
.topbar button.reset-sm{
background:#dc2626;
color:#fff;
@ -35,20 +91,22 @@ tr:hover{background:#2d3748}
cursor:pointer;
font-size:.9rem;
}
.topbar button.reset-sm:hover{background:#b91c1c}
.flash{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-bottom:1rem}
.flash.error{background:#450a0a}
.reset-box{display:none}
.topbar button.reset-sm:hover{background:#b91c1c;}
/* Hidden legacy reset box (kept CSS class for compatibility, not displayed) */
.reset-box{display:none;}
</style>
</head>
<body>
<div class="container">
<!-- Topbar: Reset System button and Log out link -->
<div class="topbar">
<form method="POST" action="{{ url_for('admin_reset_system') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
<button type="submit" class="reset-sm">Reset System</button>
</form>
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
</div>
<h1>Theme Song Booth — Admin Dashboard</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
@ -57,6 +115,7 @@ tr:hover{background:#2d3748}
{% endfor %}
{% endwith %}
<!-- Status filter links -->
<div class="filters">
<a href="{{ url_for('admin_dashboard') }}" class="{% if not current_status %}active{% endif %}">All</a>
{% for key,label in statuses.items() %}
@ -64,6 +123,7 @@ tr:hover{background:#2d3748}
{% endfor %}
</div>
<!-- Requests table -->
<table>
<thead>
<tr>
@ -85,7 +145,8 @@ tr:hover{background:#2d3748}
<td>{{ r.style_genre or '-' }}</td>
<td><span class="status-badge {{ r.status }}">{{ statuses[r.status] }}</span></td>
<td>{% if r.customer_approved != 'none' %}{{ r.customer_approved.upper() }}{% else %}-{% endif %}</td>
<td class="actions"><a href="{{ url_for('admin_request', rid=r.id) }}">Open</a>
<td class="actions">
<a href="{{ url_for('admin_request', rid=r.id) }}">Open</a>
<form method="POST" action="{{ url_for('admin_delete_request', rid=r.id) }}" style="display:inline" onsubmit="return confirm('ARE YOU SURE you want to delete request #{{ r.id }} for {{ r.name }}? This cannot be undone.')">
<button type="submit" class="delete">Delete</button>
</form>
@ -97,14 +158,6 @@ tr:hover{background:#2d3748}
{% endif %}
</tbody>
</table>
<div class="reset-box">
<h2>⚠️ Reset System</h2>
<p>Use this once at the start of an event to clear all orders and uploaded files.</p>
<form method="POST" action="{{ url_for('admin_reset_system') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
<button type="submit">Reset System</button>
</form>
</div>
</div>
</body>
</html>

View file

@ -5,13 +5,51 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title>
<style>
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;display:flex;justify-content:center;align-items:center;min-height:100vh}
form{background:#1f2937;padding:2rem;border-radius:1rem;width:100%;max-width:360px}
h1{margin-top:0;color:#60a5fa}
label{display:block;margin-top:1rem;font-weight:600}
input{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box}
button{margin-top:1.5rem;width:100%;padding:.8rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer}
.flash{margin-top:1rem;color:#f87171}
/*
Minimal login page for the operator dashboard.
Centered card with dark theme matching the rest of the app.
*/
body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
background:#111827;
color:#f3f4f6;
margin:0;
padding:1rem;
display:flex;
justify-content:center;
align-items:center;
min-height:100vh;
}
form{
background:#1f2937;
padding:2rem;
border-radius:1rem;
width:100%;
max-width:360px;
}
h1{margin-top:0;color:#60a5fa;}
label{display:block;margin-top:1rem;font-weight:600;}
input{
width:100%;
padding:.6rem;
border-radius:.5rem;
border:1px solid #374151;
background:#111827;
color:#f3f4f6;
box-sizing:border-box;
}
button{
margin-top:1.5rem;
width:100%;
padding:.8rem;
border:none;
border-radius:.5rem;
background:#3b82f6;
color:#fff;
font-weight:700;
cursor:pointer;
}
.flash{margin-top:1rem;color:#f87171;}
</style>
</head>
<body>
@ -19,11 +57,13 @@ button{margin-top:1.5rem;width:100%;padding:.8rem;border:none;border-radius:.5re
<h1>Booth Admin</h1>
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
<button type="submit">Log In</button>
</form>
</body>

View file

@ -5,28 +5,85 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request #{{ req.id }} — Admin</title>
<style>
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
.container{max-width:900px;margin:0 auto}
h1,h2{color:#60a5fa}
a{color:#93c5fd}
.section{background:#1f2937;padding:1rem;border-radius:.5rem;margin-bottom:1rem}
label{display:block;margin-top:.8rem;font-weight:600}
input,textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box;font:inherit}
textarea{min-height:120px}
button{padding:.7rem 1rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer;margin-top:.5rem}
button.secondary{background:#4b5563}
button.danger{background:#dc2626}
button.success{background:#10b981}
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem}
.flash{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-bottom:1rem}
.flash.error{background:#450a0a}
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.info-grid div{background:#111827;padding:.6rem;border-radius:.5rem}
.approved-box{font-size:1.2rem;font-weight:bold;color:#fbbf24}
.status-badge{display:inline-block;padding:.25rem .6rem;border-radius:9999px;font-size:.8rem;font-weight:600;background:#374151;color:#f3f4f6;margin-right:.3rem}
.status-badge.ok{background:#10b981;color:#000}
.status-badge.missing{background:#ef4444;color:#000}
.status-badge.sent{background:#60a5fa;color:#000}
/*
Single-request admin detail page.
Four sections:
1. Generate Suno prompt (copy to Hermes, paste response, extract/copy)
2. Upload Songs (with status badges)
3. Notify Customer (preview email status + send button)
4. Payment & Delivery (approval status + deliver button)
*/
body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
background:#111827;
color:#f3f4f6;
margin:0;
padding:1rem;
line-height:1.5;
}
.container{max-width:900px;margin:0 auto;}
h1,h2{color:#60a5fa;}
a{color:#93c5fd;}
.section{
background:#1f2937;
padding:1rem;
border-radius:.5rem;
margin-bottom:1rem;
}
label{display:block;margin-top:.8rem;font-weight:600;}
input,textarea{
width:100%;
padding:.6rem;
border-radius:.5rem;
border:1px solid #374151;
background:#111827;
color:#f3f4f6;
box-sizing:border-box;
font:inherit;
}
textarea{min-height:120px;}
button{
padding:.7rem 1rem;
border:none;
border-radius:.5rem;
background:#3b82f6;
color:#fff;
font-weight:700;
cursor:pointer;
margin-top:.5rem;
}
button.secondary{background:#4b5563;}
button.danger{background:#dc2626;}
button.success{background:#10b981;}
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem;}
.flash{
padding:.8rem;
background:#064e3b;
border-radius:.5rem;
margin-bottom:1rem;
}
.flash.error{background:#450a0a;}
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;}
.info-grid div{
background:#111827;
padding:.6rem;
border-radius:.5rem;
}
.approved-box{font-size:1.2rem;font-weight:bold;color:#fbbf24;}
.status-badge{
display:inline-block;
padding:.25rem .6rem;
border-radius:9999px;
font-size:.8rem;
font-weight:600;
background:#374151;
color:#f3f4f6;
margin-right:.3rem;
}
.status-badge.ok{background:#10b981;color:#000;}
.status-badge.missing{background:#ef4444;color:#000;}
.status-badge.sent{background:#60a5fa;color:#000;}
.copy-hint{font-size:.85rem;color:#9ca3af;margin-top:.3rem;}
</style>
</head>
<body>
@ -40,6 +97,7 @@ button.success{background:#10b981}
{% endfor %}
{% endwith %}
<!-- Section 1: Customer info summary -->
<div class="section">
<h2>Customer Info</h2>
<div class="info-grid">
@ -52,6 +110,7 @@ button.success{background:#10b981}
<p><strong>Extra requests:</strong><br>{{ req.extra_requests or '-' }}</p>
</div>
<!-- Section 2: Generate Suno prompt -->
<div class="section">
<h2>1. Generate Suno Prompt</h2>
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
@ -85,6 +144,7 @@ button.success{background:#10b981}
</form>
</div>
<!-- Section 3: Upload songs -->
<div class="section">
<h2>2. Upload Songs</h2>
<p>
@ -103,6 +163,7 @@ button.success{background:#10b981}
<span class="status-badge missing">❌ Not uploaded</span>
{% endif %}
</p>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="upload_songs">
<label for="song_a">Version A MP3</label>
@ -115,6 +176,7 @@ button.success{background:#10b981}
</form>
</div>
<!-- Section 4: Notify customer -->
<div class="section">
<h2>3. Notify Customer</h2>
<p>
@ -132,9 +194,11 @@ button.success{background:#10b981}
</form>
</div>
<!-- Section 5: Payment & delivery -->
<div class="section">
<h2>4. Payment & Delivery</h2>
<p>Customer approved:
<p>
Customer approved:
{% if req.customer_approved == 'none' %}
<span class="approved-box">Nothing yet</span>
{% else %}
@ -160,6 +224,13 @@ button.success{background:#10b981}
</div>
<script>
/*
Client-side helpers for the admin detail page.
- copyPromptForHermes(): builds a plain-text prompt and copies it.
- extractHermesResponse(): parses Title/Style/Lyrics from pasted text.
- copyToClipboard(): copies a field to the clipboard for pasting into Suno.
*/
function copyPromptForHermes() {
const data = {
name: {{ req.name | tojson }},

View file

@ -5,20 +5,67 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Theme Song</title>
<style>
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
.container{max-width:640px;margin:0 auto;background:#1f2937;padding:1.5rem;border-radius:1rem}
h1{color:#60a5fa}
.player{background:#111827;padding:1rem;border-radius:.5rem;margin:1rem 0}
audio{width:100%;margin-top:.5rem}
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1rem}
button{flex:1;min-width:120px;padding:.8rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer}
button.selected{background:#10b981}
button.both{background:#8b5cf6}
button.revision{background:#f59e0b;color:#000}
form{margin-top:1rem}
textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box;min-height:80px}
.status{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-top:1rem}
.status.waiting{background:#3f3f46}
/*
Private customer player page.
Shows two audio players for Version A and Version B,
plus approval buttons and a revision note form.
*/
body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
background:#111827;
color:#f3f4f6;
margin:0;
padding:1rem;
line-height:1.5;
}
.container{
max-width:640px;
margin:0 auto;
background:#1f2937;
padding:1.5rem;
border-radius:1rem;
}
h1{color:#60a5fa;}
.player{
background:#111827;
padding:1rem;
border-radius:.5rem;
margin:1rem 0;
}
audio{width:100%;margin-top:.5rem;}
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1rem;}
button{
flex:1;
min-width:120px;
padding:.8rem;
border:none;
border-radius:.5rem;
background:#3b82f6;
color:#fff;
font-weight:700;
cursor:pointer;
}
button.selected{background:#10b981;}
button.both{background:#8b5cf6;}
button.revision{background:#f59e0b;color:#000;}
form{margin-top:1rem;}
textarea{
width:100%;
padding:.6rem;
border-radius:.5rem;
border:1px solid #374151;
background:#111827;
color:#f3f4f6;
box-sizing:border-box;
min-height:80px;
}
.status{
padding:.8rem;
background:#064e3b;
border-radius:.5rem;
margin-top:1rem;
}
.status.waiting{background:#3f3f46;}
</style>
</head>
<body>
@ -26,17 +73,20 @@ textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;b
<h1>🎧 Your Custom Theme Song</h1>
<p>Hi {{ req.name }}! Listen to both versions and pick the one you want.</p>
<!-- Version A player -->
<div class="player">
<h3>Version A</h3>
<audio controls src="{{ url_for('audio', token=req.player_token, version='a') }}"></audio>
</div>
<!-- Version B player -->
<div class="player">
<h3>Version B</h3>
<audio controls src="{{ url_for('audio', token=req.player_token, version='b') }}"></audio>
</div>
{% if req.status in ['songs_uploaded','awaiting_payment','paid','delivered'] %}
<!-- Approval form: customer picks A, B, or both -->
<form method="POST" action="{{ url_for('approve', token=req.player_token) }}">
<input type="hidden" name="choice" id="choice">
<div class="actions">
@ -46,6 +96,7 @@ textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;b
</div>
</form>
<!-- Revision form: customer asks for changes -->
<form method="POST" action="{{ url_for('revise', token=req.player_token) }}">
<label for="revision_note">Or ask for changes:</label>
<textarea id="revision_note" name="revision_note" placeholder="e.g. make the chorus louder, swap a lyric..."></textarea>
@ -56,11 +107,17 @@ textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;b
{% endif %}
{% if req.status == 'awaiting_payment' %}
<div class="status waiting"><strong>Thanks for choosing {{ req.customer_approved.upper() }}!</strong> Please head to the booth to finalize payment and collect your files.</div>
<!-- Shown after customer approves a version -->
<div class="status waiting">
<strong>Thanks for choosing {{ req.customer_approved.upper() }}!</strong> Please head to the booth to finalize payment and collect your files.
</div>
{% endif %}
{% if req.status == 'delivered' %}
<div class="status"><strong>Delivered! ✅</strong> Check your email for the MP3 attachment(s).</div>
<!-- Shown after operator marks paid and delivers -->
<div class="status">
<strong>Delivered! ✅</strong> Check your email for the MP3 attachment(s).
</div>
{% endif %}
</div>

View file

@ -5,9 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request Your Theme Song</title>
<style>
*{
box-sizing:border-box;
}
/*
Customer-facing request page.
Designed to look fun and inviting on a convention booth tablet or phone.
Uses a gradient background, banner image, and a styled card form.
*/
*{box-sizing:border-box;}
body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%);
@ -17,10 +20,7 @@ body{
line-height:1.5;
min-height:100vh;
}
.container{
max-width:680px;
margin:0 auto;
}
.container{max-width:680px;margin:0 auto;}
.banner{
width:100%;
border-radius:1rem;
@ -66,10 +66,7 @@ input:focus,textarea:focus{
border-color:#60a5fa;
box-shadow:0 0 0 3px rgba(96,165,250,.2);
}
textarea{
min-height:90px;
resize:vertical;
}
textarea{min-height:90px;resize:vertical;}
button{
margin-top:1.5rem;
width:100%;
@ -102,6 +99,7 @@ button:hover{
</head>
<body>
<div class="container">
<!-- Banner graphic for the booth. Served from /static. -->
<img src="{{ url_for('static', filename='Trollgorithm_booth.jpg') }}" alt="Trollgorithm Theme Song Booth" class="banner">
<div class="card">

View file

@ -5,10 +5,32 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request Received</title>
<style>
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
.container{max-width:640px;margin:0 auto;background:#1f2937;padding:1.5rem;border-radius:1rem;text-align:center}
h1{color:#34d399}
.token{font-family:monospace;background:#111827;padding:.6rem;border-radius:.5rem;word-break:break-all}
/*
Simple confirmation page shown after a customer submits a request.
Gives them a request number they can reference at the booth.
*/
body{
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
background:#111827;
color:#f3f4f6;
margin:0;
padding:1rem;
line-height:1.5;
display:flex;
justify-content:center;
align-items:center;
min-height:100vh;
}
.container{
max-width:640px;
margin:0 auto;
background:#1f2937;
padding:1.5rem;
border-radius:1rem;
text-align:center;
}
h1{color:#34d399;}
.note{font-size:.9rem;color:#9ca3af;}
</style>
</head>
<body>