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 APP_SECRET_KEY=change-me-in-production
ADMIN_PASSWORD=change-me ADMIN_PASSWORD=change-me
SMTP_HOST=mailroot8.namespro.ca SMTP_HOST=mailroot8.namespro.ca
@ -8,9 +28,9 @@ SMTP_FROM=ai@hallsworth.ca
ADMIN_ALERT_EMAIL= ADMIN_ALERT_EMAIL=
PUBLIC_BASE_URL=http://127.0.0.1:5000 PUBLIC_BASE_URL=http://127.0.0.1:5000
BOOTH_NAME=Trollgorithm Theme Songs BOOTH_NAME=Trollgorithm Theme Songs
DATABASE=/app/data/booth.db
UPLOAD_FOLDER=/app/uploads
INTERNAL_PORT=8000 INTERNAL_PORT=8000
HOST_PORT=127.0.0.1:8000 HOST_PORT=127.0.0.1:8000
PRICE_PER_VERSION=10.00 PRICE_PER_VERSION=10.00
CURRENCY=CAD 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 FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# Install ffmpeg; clean apt cache to keep image small.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \ && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Python requirements first for layer caching.
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy application source, templates, static files, etc.
COPY . . COPY . .
# Avoid writing .pyc files and ensure stdout is unbuffered.
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=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 RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app
USER boothuser USER boothuser
# Default internal port; override with INTERNAL_PORT env var # Default internal port; override with INTERNAL_PORT env var.
EXPOSE 8000 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 CMD gunicorn -b 0.0.0.0:${INTERNAL_PORT:-8000} --access-logfile - app:app

132
README.md
View file

@ -1,18 +1,54 @@
# Theme Song Booth # 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`. - **Customer request page** (`/request`) — booth visitors enter their name, email, hobbies, notable facts, preferred genre, and extra requests. A branded banner image is shown.
2. Operator generates a Suno Custom Mode prompt via Hermes and saves it in the admin detail page. - **Operator dashboard** (`/admin`) — queue of all requests with status filters, per-request detail page, and system reset.
3. Operator generates two song versions in Suno and uploads the MP3s in admin. - **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.
4. Operator clicks **Send Preview Link**. Customer receives an email with a private player page. - **Song upload** — operator uploads Version A and Version B MP3s.
5. Customer listens to Version A and Version B, then approves one/both or requests changes. - **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.
6. Operator sees the approval alert, collects payment via Square reader, then clicks **Mark Paid & Deliver**. - **Payment and delivery** — operator enters a Square payment reference and clicks **Mark Paid & Deliver**. The approved MP3(s) are emailed as attachments.
7. Customer receives the approved MP3(s) as email 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 ```bash
cd /home/jess/workspace/theme-song-booth 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 .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 ## 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 Portainer.
1. Log in to your Portainer instance.
2. Go to **Stacks****Add stack**. 2. Go to **Stacks****Add stack**.
3. Choose **Repository** and paste: 3. Choose **Repository**:
- URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git` - URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git`
- Branch: `main` - Branch: `main`
- Compose path: `docker-compose.yml` - Compose path: `docker-compose.yml`
4. Add environment variables directly in Portainer under **Environment variables**: 4. Add environment variables:
- `APP_SECRET_KEY` — long random string
- `ADMIN_PASSWORD` — password for `/admin` | Variable | Required | Purpose |
- `SMTP_PASS``b3ZzD@eM!MkqVS8P` |---|---|---|
- `PUBLIC_BASE_URL` — your HTTPS domain | `APP_SECRET_KEY` | Yes | Long random string for Flask sessions. Generate with `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
- `HOST_PORT` — default `127.0.0.1:8000` | `ADMIN_PASSWORD` | Yes | Password for `/admin`. |
- `BOOTH_NAME` — optional | `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. 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 ```bash
python init_db.py
```
- The `docker-compose.yml` uses named volumes (`booth-data`, `booth-uploads`) so Portainer handles persistence automatically. 7. Point your reverse proxy at the `HOST_PORT` you chose.
- For a pre-built image instead of repo build, replace the `build:` block with an `image:` line pointing to your registry. 8. Print or display a QR code pointing to `https://your-domain/request`.
- Update the stack after each push to redeploy the latest code.
## Files ### Updating the deployment
- `app.py` — Flask application with public/admin routes and email logic. After each push to GitLab, go to Portainer → **Stacks**`theme-song-booth`**Pull and redeploy** to rebuild from the repo.
- `models.py` — SQLite schema and helper functions.
- `config.py` — Configuration loaded from environment. ## Important notes
- `templates/` — Jinja2 HTML templates.
- `init_db.py` — Standalone script to create the SQLite database. - **No `.env` file in production.** `docker-compose.yml` passes variables directly from Portainer. This avoids Portainer's `env_file not found` error.
- `Dockerfile` / `docker-compose.yml` — Container packaging for Portainer. - **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 os
import shutil import shutil
import smtplib import smtplib
@ -5,16 +32,26 @@ import ssl
from email.message import EmailMessage from email.message import EmailMessage
from pathlib import Path 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 flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
# Project imports
from config import Config 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 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 = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
# Ensure the SQLite connection is closed at the end of each request.
app.teardown_appcontext(close_db) app.teardown_appcontext(close_db)
# Human-readable labels for each status value stored in the database.
STATUS_LABELS = { STATUS_LABELS = {
'pending': 'Pending', 'pending': 'Pending',
'prompt_ready': 'Prompt Ready', 'prompt_ready': 'Prompt Ready',
@ -24,27 +61,46 @@ STATUS_LABELS = {
'delivered': 'Delivered', 'delivered': 'Delivered',
} }
# ---------------- helpers ---------------- # ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def is_admin(): def is_admin():
"""Return True if the current browser session is logged in as admin."""
return session.get('admin') is True return session.get('admin') is True
def require_admin(): def require_admin():
"""Redirect to the admin login page if the user is not logged in."""
if not is_admin(): if not is_admin():
return redirect(url_for('admin_login')) return redirect(url_for('admin_login'))
def admin_password_ok(pw): def admin_password_ok(pw):
"""Check the submitted admin password against the configured one."""
return pw and pw == current_app.config['ADMIN_PASSWORD'] return pw and pw == current_app.config['ADMIN_PASSWORD']
def allowed_file(filename): 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'] return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
def upload_path(request_id): 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 = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id)
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
return p return p
def save_upload(request_id, file_obj, version): 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 == '': if not file_obj or file_obj.filename == '':
return None return None
if not allowed_file(file_obj.filename): if not allowed_file(file_obj.filename):
@ -55,7 +111,15 @@ def save_upload(request_id, file_obj, version):
file_obj.save(p / filename) file_obj.save(p / filename)
return str(p / filename) return str(p / filename)
def send_email(to, subject, body, attachments=None): 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 cfg = current_app.config
if not cfg['SMTP_PASS']: if not cfg['SMTP_PASS']:
raise RuntimeError('SMTP_PASS is not configured') raise RuntimeError('SMTP_PASS is not configured')
@ -66,6 +130,7 @@ def send_email(to, subject, body, attachments=None):
msg['Subject'] = subject msg['Subject'] = subject
msg.set_content(body) msg.set_content(body)
# Attach any MP3 files as audio/mpeg attachments.
if attachments: if attachments:
for path, name in attachments: for path, name in attachments:
with open(path, 'rb') as f: 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.login(cfg['SMTP_USER'], cfg['SMTP_PASS'])
server.send_message(msg) server.send_message(msg)
# ---------------- public ----------------
# ---------------------------------------------------------------------------
# Public customer routes
# ---------------------------------------------------------------------------
@app.route('/') @app.route('/')
def index(): def index():
"""Root route: redirect customers straight to the request form."""
return redirect(url_for('request_form')) return redirect(url_for('request_form'))
@app.route('/request', methods=['GET', 'POST']) @app.route('/request', methods=['GET', 'POST'])
def request_form(): 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': if request.method == 'POST':
rid = create_request( rid = create_request(
name=request.form.get('name', '').strip(), name=request.form.get('name', '').strip(),
@ -97,22 +172,34 @@ def request_form():
return redirect(url_for('thanks', rid=rid)) return redirect(url_for('thanks', rid=rid))
return render_template('request.html') return render_template('request.html')
@app.route('/thanks/<int:rid>') @app.route('/thanks/<int:rid>')
def thanks(rid): def thanks(rid):
"""Confirmation page shown after a customer submits a request."""
req = get_request_by_id(rid) req = get_request_by_id(rid)
if not req: if not req:
abort(404) abort(404)
return render_template('thanks.html', req=req) return render_template('thanks.html', req=req)
@app.route('/play/<token>') @app.route('/play/<token>')
def 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) req = get_request_by_token(token)
if not req: if not req:
abort(404) abort(404)
return render_template('player.html', req=req) return render_template('player.html', req=req)
@app.route('/play/<token>/approve', methods=['POST']) @app.route('/play/<token>/approve', methods=['POST'])
def approve(token): 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) req = get_request_by_token(token)
if not req: if not req:
abort(404) abort(404)
@ -123,28 +210,37 @@ def approve(token):
update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc()) 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'] # alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
# if alert_to: ... # if alert_to: ...
flash('Thanks! Please return to the booth to finalize payment.', 'success') flash('Thanks! Please return to the booth to finalize payment.', 'success')
return redirect(url_for('play', token=token)) return redirect(url_for('play', token=token))
@app.route('/play/<token>/revise', methods=['POST']) @app.route('/play/<token>/revise', methods=['POST'])
def revise(token): 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) req = get_request_by_token(token)
if not req: if not req:
abort(404) abort(404)
note = request.form.get('revision_note', '').strip() note = request.form.get('revision_note', '').strip()
update_request(req['id'], revision_note=note, status='songs_uploaded') update_request(req['id'], revision_note=note, status='songs_uploaded')
# Revision feedback is stored in the DB and surfaced on the admin dashboard. # NOTE: No operator email is sent; the dashboard is the single queue.
# No operator email is sent — the dashboard is the single queue.
flash('Your feedback has been saved. We will regenerate and update you.', 'success') flash('Your feedback has been saved. We will regenerate and update you.', 'success')
return redirect(url_for('play', token=token)) return redirect(url_for('play', token=token))
@app.route('/audio/<token>/<version>.mp3') @app.route('/audio/<token>/<version>.mp3')
def audio(token, version): 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) req = get_request_by_token(token)
if not req: if not req:
abort(404) abort(404)
@ -156,10 +252,14 @@ def audio(token, version):
abort(404) abort(404)
return send_from_directory(Path(path).parent, Path(path).name) return send_from_directory(Path(path).parent, Path(path).name)
# ---------------- admin ----------------
# ---------------------------------------------------------------------------
# Admin routes
# ---------------------------------------------------------------------------
@app.route('/admin/login', methods=['GET', 'POST']) @app.route('/admin/login', methods=['GET', 'POST'])
def admin_login(): def admin_login():
"""Simple session-based admin login. Password is set via ADMIN_PASSWORD env var."""
if is_admin(): if is_admin():
return redirect(url_for('admin_dashboard')) return redirect(url_for('admin_dashboard'))
if request.method == 'POST': if request.method == 'POST':
@ -169,13 +269,20 @@ def admin_login():
flash('Invalid password.', 'error') flash('Invalid password.', 'error')
return render_template('admin/login.html') return render_template('admin/login.html')
@app.route('/admin/logout') @app.route('/admin/logout')
def admin_logout(): def admin_logout():
"""Clear the admin session."""
session.pop('admin', None) session.pop('admin', None)
return redirect(url_for('admin_login')) return redirect(url_for('admin_login'))
@app.route('/admin') @app.route('/admin')
def admin_dashboard(): def admin_dashboard():
"""
Main operator queue.
Optional ?status= filter lets operators focus on one state at a time.
"""
redir = require_admin() redir = require_admin()
if redir: if redir:
return redir return redir
@ -183,8 +290,15 @@ def admin_dashboard():
requests = list_requests(status_filter) requests = list_requests(status_filter)
return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=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']) @app.route('/admin/request/<int:rid>', methods=['GET', 'POST'])
def admin_request(rid): 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() redir = require_admin()
if redir: if redir:
return redir return redir
@ -192,6 +306,7 @@ def admin_request(rid):
if not req: if not req:
abort(404) abort(404)
# Small helpers exposed to the template for status badges.
def file_exists(path): def file_exists(path):
return bool(path and Path(path).exists()) return bool(path and Path(path).exists())
@ -202,6 +317,7 @@ def admin_request(rid):
action = request.form.get('action') action = request.form.get('action')
if action == 'save_prompt': if action == 'save_prompt':
# Store the generated title/style/lyrics and mark prompt ready.
update_request(rid, update_request(rid,
suno_title=request.form.get('suno_title', '').strip(), suno_title=request.form.get('suno_title', '').strip(),
suno_style=request.form.get('suno_style', '').strip(), suno_style=request.form.get('suno_style', '').strip(),
@ -211,6 +327,7 @@ def admin_request(rid):
flash('Prompt saved.', 'success') flash('Prompt saved.', 'success')
elif action == 'upload_songs': 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') a_path = save_upload(rid, request.files.get('song_a'), 'a')
b_path = save_upload(rid, request.files.get('song_b'), 'b') b_path = save_upload(rid, request.files.get('song_b'), 'b')
fields = {} fields = {}
@ -224,6 +341,7 @@ def admin_request(rid):
flash('Songs uploaded.', 'success') flash('Songs uploaded.', 'success')
elif action == 'notify_customer': 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']): if not (req['song_a_path'] and req['song_b_path']):
flash('Both songs must be uploaded first.', 'error') flash('Both songs must be uploaded first.', 'error')
else: else:
@ -237,6 +355,7 @@ def admin_request(rid):
flash(f'Failed to send preview email: {e}', 'error') flash(f'Failed to send preview email: {e}', 'error')
elif action == 'mark_paid_deliver': elif action == 'mark_paid_deliver':
# Finalize: record Square payment ref, attach approved MP3s, email customer.
if req['customer_approved'] == 'none': if req['customer_approved'] == 'none':
flash('Customer has not approved a version yet.', 'error') flash('Customer has not approved a version yet.', 'error')
else: 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) 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']) @app.route('/admin/request/<int:rid>/delete', methods=['POST'])
def admin_delete_request(rid): def admin_delete_request(rid):
"""Delete a single request and remove its uploaded MP3 files."""
redir = require_admin() redir = require_admin()
if redir: if redir:
return redir return redir
@ -273,7 +394,7 @@ def admin_delete_request(rid):
if not req: if not req:
abort(404) abort(404)
# Delete uploaded files if they exist # Delete uploaded files if they exist.
for field in ('song_a_path', 'song_b_path'): for field in ('song_a_path', 'song_b_path'):
path = req.get(field) path = req.get(field)
if path and Path(path).exists(): if path and Path(path).exists():
@ -281,7 +402,7 @@ def admin_delete_request(rid):
Path(path).unlink() Path(path).unlink()
except OSError: except OSError:
pass pass
# Remove empty upload directory # Remove empty upload directory.
upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid) upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid)
if upload_dir.exists(): if upload_dir.exists():
try: try:
@ -293,8 +414,14 @@ def admin_delete_request(rid):
flash(f'Request #{rid} deleted.', 'success') flash(f'Request #{rid} deleted.', 'success')
return redirect(url_for('admin_dashboard')) return redirect(url_for('admin_dashboard'))
@app.route('/admin/reset', methods=['POST']) @app.route('/admin/reset', methods=['POST'])
def admin_reset_system(): 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() redir = require_admin()
if redir: if redir:
return redir return redir
@ -314,12 +441,18 @@ def admin_reset_system():
flash('System reset complete. All orders and files have been cleared.', 'success') flash('System reset complete. All orders and files have been cleared.', 'success')
return redirect(url_for('admin_dashboard')) return redirect(url_for('admin_dashboard'))
# ---------------- init ----------------
# ---------------------------------------------------------------------------
# CLI and entry point
# ---------------------------------------------------------------------------
@app.cli.command('init-db') @app.cli.command('init-db')
def init_db_command(): def init_db_command():
"""Flask CLI command: flask --app app init-db"""
init_db() init_db()
print('Database initialized.') print('Database initialized.')
if __name__ == '__main__': if __name__ == '__main__':
# Development-only entry point. Production uses gunicorn (see Dockerfile).
app.run(debug=True, host='0.0.0.0') 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 import os
from dotenv import load_dotenv from dotenv import load_dotenv
# Load variables from .env file if present (development mode).
load_dotenv() load_dotenv()
class Config: 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') 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') 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') UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
# Only MP3 uploads are allowed.
ALLOWED_EXTENSIONS = {'mp3'} ALLOWED_EXTENSIONS = {'mp3'}
# SMTP server settings for sending customer emails.
SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca') SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca')
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465')) SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))
SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca') SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca')
SMTP_PASS = os.environ.get('SMTP_PASS', '') SMTP_PASS = os.environ.get('SMTP_PASS', '')
SMTP_FROM = os.environ.get('SMTP_FROM', 'ai@hallsworth.ca') 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', '') 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', '') 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') 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') 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')) 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')) PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00'))
CURRENCY = os.environ.get('CURRENCY', 'CAD') 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: services:
booth: booth:
build: build:
@ -12,10 +25,13 @@ services:
- SMTP_USER=${SMTP_USER:-ai@hallsworth.ca} - SMTP_USER=${SMTP_USER:-ai@hallsworth.ca}
- SMTP_PASS=${SMTP_PASS} - SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM:-ai@hallsworth.ca} - SMTP_FROM=${SMTP_FROM:-ai@hallsworth.ca}
- ADMIN_ALERT_EMAIL=${ADMIN_ALERT_EMAIL:-}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL} - PUBLIC_BASE_URL=${PUBLIC_BASE_URL}
- BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs} - BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs}
- INTERNAL_PORT=${INTERNAL_PORT:-8000} - 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: ports:
- "${HOST_PORT:-127.0.0.1:8000}:${INTERNAL_PORT:-8000}" - "${HOST_PORT:-127.0.0.1:8000}:${INTERNAL_PORT:-8000}"
volumes: 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 os
import sys 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__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import app from app import app
from models import init_db from models import init_db
# Use the configured database path and create tables.
with app.app_context(): with app.app_context():
init_db() init_db()
print(f"Database initialized at {app.config['DATABASE']}") 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 sqlite3
import secrets import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import current_app, g from flask import current_app, g
# SQL executed by init_db() to create the requests table and indexes.
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS requests ( CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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); CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token);
""" """
def get_db(): def get_db():
"""Get or create a SQLite connection tied to the current Flask request context."""
if 'db' not in g: if 'db' not in g:
g.db = sqlite3.connect(current_app.config['DATABASE']) g.db = sqlite3.connect(current_app.config['DATABASE'])
g.db.row_factory = sqlite3.Row g.db.row_factory = sqlite3.Row
return g.db return g.db
def close_db(e=None): def close_db(e=None):
"""Close the request-scoped SQLite connection. Registered as teardown handler."""
db = g.pop('db', None) db = g.pop('db', None)
if db is not None: if db is not None:
db.close() db.close()
def init_db(): def init_db():
"""Create the database file and tables. Safe to run multiple times."""
db = sqlite3.connect(current_app.config['DATABASE']) db = sqlite3.connect(current_app.config['DATABASE'])
db.executescript(SCHEMA) db.executescript(SCHEMA)
db.commit() db.commit()
db.close() db.close()
def new_token(): def new_token():
"""Generate a URL-safe random token used for private player links."""
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
def now_utc(): def now_utc():
"""Return current UTC time as ISO-8601 string for timestamp columns."""
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests): 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() db = get_db()
cur = db.execute( cur = db.execute(
"""INSERT INTO requests """INSERT INTO requests
@ -67,17 +97,23 @@ def create_request(name, email, hobbies, notable_facts, style_genre, extra_reque
db.commit() db.commit()
return cur.lastrowid return cur.lastrowid
def get_request_by_id(request_id): def get_request_by_id(request_id):
"""Fetch one request by numeric id. Returns dict or None."""
db = get_db() db = get_db()
row = db.execute('SELECT * FROM requests WHERE id = ?', (request_id,)).fetchone() row = db.execute('SELECT * FROM requests WHERE id = ?', (request_id,)).fetchone()
return dict(row) if row else None return dict(row) if row else None
def get_request_by_token(token): def get_request_by_token(token):
"""Fetch one request by its private player token. Returns dict or None."""
db = get_db() db = get_db()
row = db.execute('SELECT * FROM requests WHERE player_token = ?', (token,)).fetchone() row = db.execute('SELECT * FROM requests WHERE player_token = ?', (token,)).fetchone()
return dict(row) if row else None return dict(row) if row else None
def list_requests(status=None): def list_requests(status=None):
"""List all requests, optionally filtered by status, newest first."""
db = get_db() db = get_db()
if status: if status:
rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall() 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() rows = db.execute('SELECT * FROM requests ORDER BY created_at DESC').fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def update_request(request_id, **fields): def update_request(request_id, **fields):
"""
Update arbitrary columns for a request.
Example: update_request(1, status='prompt_ready', suno_style='...')
"""
if not fields: if not fields:
return return
db = get_db() db = get_db()
@ -94,12 +135,16 @@ def update_request(request_id, **fields):
db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals) db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals)
db.commit() db.commit()
def delete_request(request_id): def delete_request(request_id):
"""Delete a single request by id. Does NOT delete associated files."""
db = get_db() db = get_db()
db.execute('DELETE FROM requests WHERE id = ?', (request_id,)) db.execute('DELETE FROM requests WHERE id = ?', (request_id,))
db.commit() db.commit()
def reset_all_requests(): def reset_all_requests():
"""Delete every row in the requests table. Does NOT delete files."""
db = get_db() db = get_db()
db.execute('DELETE FROM requests') db.execute('DELETE FROM requests')
db.commit() 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 flask
gunicorn gunicorn
python-dotenv python-dotenv

View file

@ -1,110 +1,163 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard</title> <title>Admin Dashboard</title>
<style> <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} Operator dashboard queue.
h1{color:#60a5fa} Shows all requests in a table, with status filters,
.filters{margin-bottom:1rem} per-row Open/Delete actions, and a topbar Reset System button.
.filters a{color:#93c5fd;text-decoration:none;margin-right:1rem} */
.filters a.active{font-weight:bold;color:#fff} body{
table{width:100%;border-collapse:collapse;background:#1f2937;border-radius:.5rem;overflow:hidden} font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
th,td{padding:.7rem;text-align:left;border-bottom:1px solid #374151} background:#111827;
th{background:#111827;color:#9ca3af} color:#f3f4f6;
tr:hover{background:#2d3748} margin:0;
.status-badge{display:inline-block;padding:.25rem .6rem;border-radius:9999px;font-size:.8rem;font-weight:600;background:#374151} padding:1rem;
.awaiting_payment{background:#f59e0b;color:#000} line-height:1.5;
.paid,.delivered{background:#10b981;color:#000} }
.pending,.prompt_ready{background:#60a5fa;color:#000} .container{max-width:1100px;margin:0 auto;}
.songs_uploaded{background:#a78bfa;color:#000} h1{color:#60a5fa;}
.actions a{color:#93c5fd;text-decoration:none;margin-right:.8rem} .filters{margin-bottom:1rem;}
.actions button.delete{background:transparent;color:#f87171;border:none;padding:0;cursor:pointer;font:inherit;text-decoration:none;margin-right:.8rem} .filters a{
.logout{float:right;color:#f87171;text-decoration:none} color:#93c5fd;
.topbar{float:right;display:flex;gap:.75rem;align-items:center} text-decoration:none;
.topbar form{display:inline} margin-right:1rem;
.topbar button.reset-sm{ }
background:#dc2626; .filters a.active{font-weight:bold;color:#fff;}
color:#fff; table{
border:none; width:100%;
border-radius:.4rem; border-collapse:collapse;
padding:.4rem .8rem; background:#1f2937;
font-weight:600; border-radius:.5rem;
cursor:pointer; overflow:hidden;
font-size:.9rem; }
} th,td{padding:.7rem;text-align:left;border-bottom:1px solid #374151;}
.topbar button.reset-sm:hover{background:#b91c1c} th{background:#111827;color:#9ca3af;}
.flash{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-bottom:1rem} tr:hover{background:#2d3748;}
.flash.error{background:#450a0a} .status-badge{
.reset-box{display:none} display:inline-block;
</style> 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;
border:none;
border-radius:.4rem;
padding:.4rem .8rem;
font-weight:600;
cursor:pointer;
font-size:.9rem;
}
.topbar button.reset-sm:hover{background:#b91c1c;}
/* Hidden legacy reset box (kept CSS class for compatibility, not displayed) */
.reset-box{display:none;}
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="topbar"> <!-- Topbar: Reset System button and Log out link -->
<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.')"> <div class="topbar">
<button type="submit" class="reset-sm">Reset System</button> <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.')">
</form> <button type="submit" class="reset-sm">Reset System</button>
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a> </form>
</div> <a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
<h1>Theme Song Booth — Admin Dashboard</h1> </div>
{% with messages = get_flashed_messages(with_categories=true) %} <h1>Theme Song Booth — Admin Dashboard</h1>
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<div class="filters"> {% with messages = get_flashed_messages(with_categories=true) %}
<a href="{{ url_for('admin_dashboard') }}" class="{% if not current_status %}active{% endif %}">All</a> {% for category, message in messages %}
{% for key,label in statuses.items() %} <div class="flash {{ category }}">{{ message }}</div>
<a href="{{ url_for('admin_dashboard', status=key) }}" class="{% if current_status == key %}active{% endif %}">{{ label }}</a> {% endfor %}
{% endfor %} {% endwith %}
</div>
<table> <!-- Status filter links -->
<thead> <div class="filters">
<tr> <a href="{{ url_for('admin_dashboard') }}" class="{% if not current_status %}active{% endif %}">All</a>
<th>ID</th> {% for key,label in statuses.items() %}
<th>Name</th> <a href="{{ url_for('admin_dashboard', status=key) }}" class="{% if current_status == key %}active{% endif %}">{{ label }}</a>
<th>Email</th> {% endfor %}
<th>Genre</th> </div>
<th>Status</th>
<th>Approved</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for r in requests %}
<tr>
<td>#{{ r.id }}</td>
<td>{{ r.name }}</td>
<td>{{ r.email }}</td>
<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>
<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>
</td>
</tr>
{% endfor %}
{% if not requests %}
<tr><td colspan="7">No requests found.</td></tr>
{% endif %}
</tbody>
</table>
<div class="reset-box"> <!-- Requests table -->
<h2>⚠️ Reset System</h2> <table>
<p>Use this once at the start of an event to clear all orders and uploaded files.</p> <thead>
<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.')"> <tr>
<button type="submit">Reset System</button> <th>ID</th>
</form> <th>Name</th>
</div> <th>Email</th>
</div> <th>Genre</th>
<th>Status</th>
<th>Approved</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for r in requests %}
<tr>
<td>#{{ r.id }}</td>
<td>{{ r.name }}</td>
<td>{{ r.email }}</td>
<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>
<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>
</td>
</tr>
{% endfor %}
{% if not requests %}
<tr><td colspan="7">No requests found.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</body> </body>
</html> </html>

View file

@ -1,30 +1,70 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title> <title>Admin Login</title>
<style> <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} Minimal login page for the operator dashboard.
h1{margin-top:0;color:#60a5fa} Centered card with dark theme matching the rest of the app.
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} body{
button{margin-top:1.5rem;width:100%;padding:.8rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer} font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
.flash{margin-top:1rem;color:#f87171} background:#111827;
</style> 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> </head>
<body> <body>
<form method="POST"> <form method="POST">
<h1>Booth Admin</h1> <h1>Booth Admin</h1>
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus> <input type="password" id="password" name="password" required autofocus>
{% with messages = get_flashed_messages() %}
{% if messages %} {% with messages = get_flashed_messages() %}
<div class="flash">{{ messages[0] }}</div> {% if messages %}
{% endif %} <div class="flash">{{ messages[0] }}</div>
{% endwith %} {% endif %}
<button type="submit">Log In</button> {% endwith %}
</form>
<button type="submit">Log In</button>
</form>
</body> </body>
</html> </html>

View file

@ -1,212 +1,283 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request #{{ req.id }} — Admin</title> <title>Request #{{ req.id }} — Admin</title>
<style> <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} Single-request admin detail page.
h1,h2{color:#60a5fa} Four sections:
a{color:#93c5fd} 1. Generate Suno prompt (copy to Hermes, paste response, extract/copy)
.section{background:#1f2937;padding:1rem;border-radius:.5rem;margin-bottom:1rem} 2. Upload Songs (with status badges)
label{display:block;margin-top:.8rem;font-weight:600} 3. Notify Customer (preview email status + send button)
input,textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box;font:inherit} 4. Payment & Delivery (approval status + deliver button)
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} body{
button.secondary{background:#4b5563} font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
button.danger{background:#dc2626} background:#111827;
button.success{background:#10b981} color:#f3f4f6;
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem} margin:0;
.flash{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-bottom:1rem} padding:1rem;
.flash.error{background:#450a0a} line-height:1.5;
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem} }
.info-grid div{background:#111827;padding:.6rem;border-radius:.5rem} .container{max-width:900px;margin:0 auto;}
.approved-box{font-size:1.2rem;font-weight:bold;color:#fbbf24} h1,h2{color:#60a5fa;}
.status-badge{display:inline-block;padding:.25rem .6rem;border-radius:9999px;font-size:.8rem;font-weight:600;background:#374151;color:#f3f4f6;margin-right:.3rem} a{color:#93c5fd;}
.status-badge.ok{background:#10b981;color:#000} .section{
.status-badge.missing{background:#ef4444;color:#000} background:#1f2937;
.status-badge.sent{background:#60a5fa;color:#000} padding:1rem;
</style> 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> </head>
<body> <body>
<div class="container"> <div class="container">
<p><a href="{{ url_for('admin_dashboard') }}">← Dashboard</a></p> <p><a href="{{ url_for('admin_dashboard') }}">← Dashboard</a></p>
<h1>Request #{{ req.id }} — {{ req.name }}</h1> <h1>Request #{{ req.id }} — {{ req.name }}</h1>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div> <div class="flash {{ category }}">{{ message }}</div>
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
<div class="section"> <!-- Section 1: Customer info summary -->
<h2>Customer Info</h2> <div class="section">
<div class="info-grid"> <h2>Customer Info</h2>
<div><strong>Email:</strong> {{ req.email }}</div> <div class="info-grid">
<div><strong>Status:</strong> {{ statuses[req.status] }}</div> <div><strong>Email:</strong> {{ req.email }}</div>
</div> <div><strong>Status:</strong> {{ statuses[req.status] }}</div>
<p><strong>Hobbies:</strong><br>{{ req.hobbies or '-' }}</p> </div>
<p><strong>Notable facts:</strong><br>{{ req.notable_facts or '-' }}</p> <p><strong>Hobbies:</strong><br>{{ req.hobbies or '-' }}</p>
<p><strong>Style / genre:</strong><br>{{ req.style_genre or '-' }}</p> <p><strong>Notable facts:</strong><br>{{ req.notable_facts or '-' }}</p>
<p><strong>Extra requests:</strong><br>{{ req.extra_requests or '-' }}</p> <p><strong>Style / genre:</strong><br>{{ req.style_genre or '-' }}</p>
</div> <p><strong>Extra requests:</strong><br>{{ req.extra_requests or '-' }}</p>
</div>
<div class="section"> <!-- Section 2: Generate Suno prompt -->
<h2>1. Generate Suno Prompt</h2> <div class="section">
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button> <h2>1. Generate Suno Prompt</h2>
<p class="copy-hint">Paste Hermes response into the box below. It will auto-extract Title, Style, and Lyrics.</p> <button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
<p class="copy-hint">Paste Hermes response into the box below. It will auto-extract Title, Style, and Lyrics.</p>
<label for="hermes_response">Paste Hermes response here</label> <label for="hermes_response">Paste Hermes response here</label>
<textarea id="hermes_response" rows="14" placeholder="Title: ...\nStyle: ...\nLyrics: ..."></textarea> <textarea id="hermes_response" rows="14" placeholder="Title: ...\nStyle: ...\nLyrics: ..."></textarea>
<div class="actions"> <div class="actions">
<button type="button" class="secondary" onclick="extractHermesResponse()">Extract Title / Style / Lyrics</button> <button type="button" class="secondary" onclick="extractHermesResponse()">Extract Title / Style / Lyrics</button>
</div> </div>
<form method="POST"> <form method="POST">
<input type="hidden" name="action" value="save_prompt"> <input type="hidden" name="action" value="save_prompt">
<label for="suno_title">Suggested Title</label> <label for="suno_title">Suggested Title</label>
<input type="text" id="suno_title" name="suno_title" value="{{ req.suno_title or '' }}" readonly> <input type="text" id="suno_title" name="suno_title" value="{{ req.suno_title or '' }}" readonly>
<label for="suno_style">Suno Style</label> <label for="suno_style">Suno Style</label>
<textarea id="suno_style" name="suno_style">{{ req.suno_style or '' }}</textarea> <textarea id="suno_style" name="suno_style">{{ req.suno_style or '' }}</textarea>
<label for="suno_lyrics">Suno Lyrics (with metatags)</label> <label for="suno_lyrics">Suno Lyrics (with metatags)</label>
<textarea id="suno_lyrics" name="suno_lyrics" rows="12">{{ req.suno_lyrics or '' }}</textarea> <textarea id="suno_lyrics" name="suno_lyrics" rows="12">{{ req.suno_lyrics or '' }}</textarea>
<div class="actions"> <div class="actions">
<button type="button" class="secondary" onclick="copyToClipboard('suno_title', 'Title')">Copy Title</button> <button type="button" class="secondary" onclick="copyToClipboard('suno_title', 'Title')">Copy Title</button>
<button type="button" class="secondary" onclick="copyToClipboard('suno_style', 'Style')">Copy Style</button> <button type="button" class="secondary" onclick="copyToClipboard('suno_style', 'Style')">Copy Style</button>
<button type="button" class="secondary" onclick="copyToClipboard('suno_lyrics', 'Lyrics')">Copy Lyrics</button> <button type="button" class="secondary" onclick="copyToClipboard('suno_lyrics', 'Lyrics')">Copy Lyrics</button>
<button type="submit">Save Prompt</button> <button type="submit">Save Prompt</button>
</div> </div>
</form> </form>
</div> </div>
<div class="section"> <!-- Section 3: Upload songs -->
<h2>2. Upload Songs</h2> <div class="section">
<p> <h2>2. Upload Songs</h2>
Version A: <p>
{% if req.song_a_path and file_exists(req.song_a_path) %} Version A:
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_a_path) }} {% if req.song_a_path and file_exists(req.song_a_path) %}
{% else %} <span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_a_path) }}
<span class="status-badge missing">❌ Not uploaded</span> {% else %}
{% endif %} <span class="status-badge missing">❌ Not uploaded</span>
</p> {% endif %}
<p> </p>
Version B: <p>
{% if req.song_b_path and file_exists(req.song_b_path) %} Version B:
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_b_path) }} {% if req.song_b_path and file_exists(req.song_b_path) %}
{% else %} <span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_b_path) }}
<span class="status-badge missing">❌ Not uploaded</span> {% else %}
{% endif %} <span class="status-badge missing">❌ Not uploaded</span>
</p> {% endif %}
<form method="POST" enctype="multipart/form-data"> </p>
<input type="hidden" name="action" value="upload_songs">
<label for="song_a">Version A MP3</label>
<input type="file" id="song_a" name="song_a" accept="audio/mpeg,.mp3">
<label for="song_b">Version B MP3</label>
<input type="file" id="song_b" name="song_b" accept="audio/mpeg,.mp3">
<div class="actions">
<button type="submit">Upload Songs</button>
</div>
</form>
</div>
<div class="section"> <form method="POST" enctype="multipart/form-data">
<h2>3. Notify Customer</h2> <input type="hidden" name="action" value="upload_songs">
<p> <label for="song_a">Version A MP3</label>
Preview email: <input type="file" id="song_a" name="song_a" accept="audio/mpeg,.mp3">
{% if req.preview_sent_at %} <label for="song_b">Version B MP3</label>
<span class="status-badge sent">✅ Sent</span> {{ req.preview_sent_at }} <input type="file" id="song_b" name="song_b" accept="audio/mpeg,.mp3">
{% else %} <div class="actions">
<span class="status-badge missing">❌ Not sent yet</span> <button type="submit">Upload Songs</button>
{% endif %} </div>
</p> </form>
<p>Both songs must be uploaded first.</p> </div>
<form method="POST">
<input type="hidden" name="action" value="notify_customer">
<button type="submit" {% if not (req.song_a_path and req.song_b_path) %}disabled{% endif %}>Send Preview Link</button>
</form>
</div>
<div class="section"> <!-- Section 4: Notify customer -->
<h2>4. Payment & Delivery</h2> <div class="section">
<p>Customer approved: <h2>3. Notify Customer</h2>
{% if req.customer_approved == 'none' %} <p>
<span class="approved-box">Nothing yet</span> Preview email:
{% else %} {% if req.preview_sent_at %}
<span class="approved-box">{{ req.customer_approved.upper() }}</span> <span class="status-badge sent">✅ Sent</span> {{ req.preview_sent_at }}
{% endif %} {% else %}
</p> <span class="status-badge missing">❌ Not sent yet</span>
<p> {% endif %}
Delivery email: </p>
{% if req.delivery_sent_at %} <p>Both songs must be uploaded first.</p>
<span class="status-badge sent">✅ Sent</span> {{ req.delivery_sent_at }} <form method="POST">
{% else %} <input type="hidden" name="action" value="notify_customer">
<span class="status-badge missing">❌ Not sent yet</span> <button type="submit" {% if not (req.song_a_path and req.song_b_path) %}disabled{% endif %}>Send Preview Link</button>
{% endif %} </form>
</p> </div>
<form method="POST">
<input type="hidden" name="action" value="mark_paid_deliver">
<label for="square_payment_ref">Square Payment Reference</label>
<input type="text" id="square_payment_ref" name="square_payment_ref" placeholder="e.g. sq0idp-... or receipt number">
<div class="actions">
<button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid & Deliver</button>
</div>
</form>
</div>
<script> <!-- Section 5: Payment & delivery -->
function copyPromptForHermes() { <div class="section">
const data = { <h2>4. Payment & Delivery</h2>
name: {{ req.name | tojson }}, <p>
hobbies: {{ req.hobbies | tojson }}, Customer approved:
notable_facts: {{ req.notable_facts | tojson }}, {% if req.customer_approved == 'none' %}
style_genre: {{ req.style_genre | tojson }}, <span class="approved-box">Nothing yet</span>
extra_requests: {{ req.extra_requests | tojson }} {% else %}
}; <span class="approved-box">{{ req.customer_approved.upper() }}</span>
const text = `Please write a Suno Custom Mode prompt for this customer. Suggest a song title too.\n\nCustomer data:\nName: ${data.name}\nHobbies: ${data.hobbies || '-'}\nNotable facts: ${data.notable_facts || '-'}\nStyle / genre: ${data.style_genre || '-'}\nExtra requests: ${data.extra_requests || '-'}\n\nReply with exactly this format:\n\nTitle: [suggested title]\n\nStyle:\n[the style description here]\n\nLyrics:\n[the lyrics here, with metatags]`; {% endif %}
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste Hermes response into the box above and click Extract.")); </p>
} <p>
Delivery email:
{% if req.delivery_sent_at %}
<span class="status-badge sent">✅ Sent</span> {{ req.delivery_sent_at }}
{% else %}
<span class="status-badge missing">❌ Not sent yet</span>
{% endif %}
</p>
<form method="POST">
<input type="hidden" name="action" value="mark_paid_deliver">
<label for="square_payment_ref">Square Payment Reference</label>
<input type="text" id="square_payment_ref" name="square_payment_ref" placeholder="e.g. sq0idp-... or receipt number">
<div class="actions">
<button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid & Deliver</button>
</div>
</form>
</div>
function extractHermesResponse() { <script>
const raw = document.getElementById('hermes_response').value; /*
if (!raw.trim()) { Client-side helpers for the admin detail page.
alert("Paste Hermes response first."); - copyPromptForHermes(): builds a plain-text prompt and copies it.
return; - extractHermesResponse(): parses Title/Style/Lyrics from pasted text.
} - copyToClipboard(): copies a field to the clipboard for pasting into Suno.
*/
const titleMatch = raw.match(/^[\s]*Title:\s*([\s\S]*?)(?=\n\s*Style:|\n\s*Lyrics:|$)/im); function copyPromptForHermes() {
const styleMatch = raw.match(/^[\s]*Style:\s*([\s\S]*?)(?=\n\s*Lyrics:|$)/im); const data = {
const lyricsMatch = raw.match(/^[\s]*Lyrics:\s*([\s\S]*?)$/im); name: {{ req.name | tojson }},
hobbies: {{ req.hobbies | tojson }},
notable_facts: {{ req.notable_facts | tojson }},
style_genre: {{ req.style_genre | tojson }},
extra_requests: {{ req.extra_requests | tojson }}
};
const text = `Please write a Suno Custom Mode prompt for this customer. Suggest a song title too.\n\nCustomer data:\nName: ${data.name}\nHobbies: ${data.hobbies || '-'}\nNotable facts: ${data.notable_facts || '-'}\nStyle / genre: ${data.style_genre || '-'}\nExtra requests: ${data.extra_requests || '-'}\n\nReply with exactly this format:\n\nTitle: [suggested title]\n\nStyle:\n[the style description here]\n\nLyrics:\n[the lyrics here, with metatags]`;
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste Hermes response into the box above and click Extract."));
}
if (titleMatch) { function extractHermesResponse() {
document.getElementById('suno_title').value = titleMatch[1].trim(); const raw = document.getElementById('hermes_response').value;
} if (!raw.trim()) {
if (styleMatch) { alert("Paste Hermes response first.");
document.getElementById('suno_style').value = styleMatch[1].trim(); return;
} }
if (lyricsMatch) {
document.getElementById('suno_lyrics').value = lyricsMatch[1].trim();
}
if (!styleMatch && !lyricsMatch) { const titleMatch = raw.match(/^[\s]*Title:\s*([\s\S]*?)(?=\n\s*Style:|\n\s*Lyrics:|$)/im);
alert("Could not find Style or Lyrics sections. Make sure the response uses the exact format."); const styleMatch = raw.match(/^[\s]*Style:\s*([\s\S]*?)(?=\n\s*Lyrics:|$)/im);
} else { const lyricsMatch = raw.match(/^[\s]*Lyrics:\s*([\s\S]*?)$/im);
alert("Title, Style, and Lyrics extracted. Use Copy buttons to paste into Suno.");
}
}
function copyToClipboard(elementId, label) { if (titleMatch) {
const el = document.getElementById(elementId); document.getElementById('suno_title').value = titleMatch[1].trim();
el.select(); }
el.setSelectionRange(0, 99999); if (styleMatch) {
navigator.clipboard.writeText(el.value).then(() => alert(label + " copied!")); document.getElementById('suno_style').value = styleMatch[1].trim();
} }
</script> if (lyricsMatch) {
</div> document.getElementById('suno_lyrics').value = lyricsMatch[1].trim();
}
if (!styleMatch && !lyricsMatch) {
alert("Could not find Style or Lyrics sections. Make sure the response uses the exact format.");
} else {
alert("Title, Style, and Lyrics extracted. Use Copy buttons to paste into Suno.");
}
}
function copyToClipboard(elementId, label) {
const el = document.getElementById(elementId);
el.select();
el.setSelectionRange(0, 99999);
navigator.clipboard.writeText(el.value).then(() => alert(label + " copied!"));
}
</script>
</div>
</body> </body>
</html> </html>

View file

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

View file

@ -1,136 +1,134 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request Your Theme Song</title> <title>Request Your Theme Song</title>
<style> <style>
*{ /*
box-sizing:border-box; Customer-facing request page.
} Designed to look fun and inviting on a convention booth tablet or phone.
body{ Uses a gradient background, banner image, and a styled card form.
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; */
background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%); *{box-sizing:border-box;}
color:#f3f4f6; body{
margin:0; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
padding:1rem; background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%);
line-height:1.5; color:#f3f4f6;
min-height:100vh; margin:0;
} padding:1rem;
.container{ line-height:1.5;
max-width:680px; min-height:100vh;
margin:0 auto; }
} .container{max-width:680px;margin:0 auto;}
.banner{ .banner{
width:100%; width:100%;
border-radius:1rem; border-radius:1rem;
box-shadow:0 10px 40px rgba(0,0,0,.4); box-shadow:0 10px 40px rgba(0,0,0,.4);
margin-bottom:1.5rem; margin-bottom:1.5rem;
} }
.card{ .card{
background:rgba(31,41,55,.9); background:rgba(31,41,55,.9);
padding:1.5rem; padding:1.5rem;
border-radius:1rem; border-radius:1rem;
box-shadow:0 10px 25px rgba(0,0,0,.3); box-shadow:0 10px 25px rgba(0,0,0,.3);
border:1px solid rgba(96,165,250,.2); border:1px solid rgba(96,165,250,.2);
} }
h1{ h1{
margin-top:0; margin-top:0;
color:#60a5fa; color:#60a5fa;
text-align:center; text-align:center;
font-size:1.7rem; font-size:1.7rem;
} }
.subtitle{ .subtitle{
text-align:center; text-align:center;
color:#9ca3af; color:#9ca3af;
margin-bottom:1.5rem; margin-bottom:1.5rem;
} }
label{ label{
display:block; display:block;
margin-top:1rem; margin-top:1rem;
font-weight:600; font-weight:600;
color:#93c5fd; color:#93c5fd;
} }
input,textarea{ input,textarea{
width:100%; width:100%;
padding:.7rem; padding:.7rem;
border-radius:.5rem; border-radius:.5rem;
border:1px solid #374151; border:1px solid #374151;
background:#111827; background:#111827;
color:#f3f4f6; color:#f3f4f6;
font:inherit; font:inherit;
margin-top:.25rem; margin-top:.25rem;
} }
input:focus,textarea:focus{ input:focus,textarea:focus{
outline:none; outline:none;
border-color:#60a5fa; border-color:#60a5fa;
box-shadow:0 0 0 3px rgba(96,165,250,.2); box-shadow:0 0 0 3px rgba(96,165,250,.2);
} }
textarea{ textarea{min-height:90px;resize:vertical;}
min-height:90px; button{
resize:vertical; margin-top:1.5rem;
} width:100%;
button{ padding:1rem;
margin-top:1.5rem; border:none;
width:100%; border-radius:.5rem;
padding:1rem; background:linear-gradient(90deg,#3b82f6,#8b5cf6);
border:none; color:#fff;
border-radius:.5rem; font-weight:700;
background:linear-gradient(90deg,#3b82f6,#8b5cf6); font-size:1.1rem;
color:#fff; cursor:pointer;
font-weight:700; box-shadow:0 4px 15px rgba(59,130,246,.4);
font-size:1.1rem; transition:transform .1s,box-shadow .1s;
cursor:pointer; }
box-shadow:0 4px 15px rgba(59,130,246,.4); button:hover{
transition:transform .1s,box-shadow .1s; transform:translateY(-2px);
} box-shadow:0 6px 20px rgba(139,92,246,.5);
button:hover{ }
transform:translateY(-2px); .note{
box-shadow:0 6px 20px rgba(139,92,246,.5); margin-top:1rem;
} font-size:.9rem;
.note{ color:#9ca3af;
margin-top:1rem; text-align:center;
font-size:.9rem; }
color:#9ca3af; .sparkle{
text-align:center; font-size:1.3rem;
} vertical-align:middle;
.sparkle{ }
font-size:1.3rem; </style>
vertical-align:middle;
}
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<img src="{{ url_for('static', filename='Trollgorithm_booth.jpg') }}" alt="Trollgorithm Theme Song Booth" class="banner"> <!-- 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"> <div class="card">
<h1><span class="sparkle">🎵</span> Get Your Custom Theme Song <span class="sparkle">🎶</span></h1> <h1><span class="sparkle">🎵</span> Get Your Custom Theme Song <span class="sparkle">🎶</span></h1>
<p class="subtitle">Tell us about yourself and we'll craft a one-of-a-kind song just for you.</p> <p class="subtitle">Tell us about yourself and we'll craft a one-of-a-kind song just for you.</p>
<form method="POST"> <form method="POST">
<label for="name">Name / persona you want in the song</label> <label for="name">Name / persona you want in the song</label>
<input type="text" id="name" name="name" required> <input type="text" id="name" name="name" required>
<label for="email">Email address</label> <label for="email">Email address</label>
<input type="email" id="email" name="email" required> <input type="email" id="email" name="email" required>
<label for="hobbies">Hobbies & interests</label> <label for="hobbies">Hobbies & interests</label>
<textarea id="hobbies" name="hobbies" placeholder="e.g. rock climbing, retro gaming, sourdough baking"></textarea> <textarea id="hobbies" name="hobbies" placeholder="e.g. rock climbing, retro gaming, sourdough baking"></textarea>
<label for="notable_facts">Notable things about you</label> <label for="notable_facts">Notable things about you</label>
<textarea id="notable_facts" name="notable_facts" placeholder="Anything fun, weird, or heroic we should mention"></textarea> <textarea id="notable_facts" name="notable_facts" placeholder="Anything fun, weird, or heroic we should mention"></textarea>
<label for="style_genre">Style / genre / mood</label> <label for="style_genre">Style / genre / mood</label>
<input type="text" id="style_genre" name="style_genre" placeholder="e.g. 80s power ballad, cinematic orchestral, lo-fi synthwave"> <input type="text" id="style_genre" name="style_genre" placeholder="e.g. 80s power ballad, cinematic orchestral, lo-fi synthwave">
<label for="extra_requests">Anything else you want in the song?</label> <label for="extra_requests">Anything else you want in the song?</label>
<textarea id="extra_requests" name="extra_requests" placeholder="Specific lyrics, vibe, clean/explicit, vocal gender..."></textarea> <textarea id="extra_requests" name="extra_requests" placeholder="Specific lyrics, vibe, clean/explicit, vocal gender..."></textarea>
<button type="submit">Submit Request</button> <button type="submit">Submit Request</button>
</form> </form>
<p class="note">Your info is only used to create and deliver your song.</p> <p class="note">Your info is only used to create and deliver your song.</p>
</div>
</div> </div>
</div>
</body> </body>
</html> </html>

View file

@ -1,22 +1,44 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request Received</title> <title>Request Received</title>
<style> <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} Simple confirmation page shown after a customer submits a request.
h1{color:#34d399} Gives them a request number they can reference at the booth.
.token{font-family:monospace;background:#111827;padding:.6rem;border-radius:.5rem;word-break:break-all} */
</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;
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> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>✅ Request Received</h1> <h1>✅ Request Received</h1>
<p>Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.</p> <p>Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.</p>
<p><strong>Your request number:</strong> #{{ req.id }}</p> <p><strong>Your request number:</strong> #{{ req.id }}</p>
<p class="note">Bring this number to the booth if you want to check on progress.</p> <p class="note">Bring this number to the booth if you want to check on progress.</p>
</div> </div>
</body> </body>
</html> </html>