Add comprehensive comments to all source files and expand documentation
This commit is contained in:
parent
9e532a1920
commit
b4f0222bd5
16 changed files with 1230 additions and 552 deletions
24
.env.example
24
.env.example
|
|
@ -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
|
||||
|
|
|
|||
22
Dockerfile
22
Dockerfile
|
|
@ -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
126
README.md
|
|
@ -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
94
REVIEW.md
Normal 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
151
app.py
|
|
@ -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')
|
||||
|
|
|
|||
36
config.py
36
config.py
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
15
init_db.py
15
init_db.py
|
|
@ -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']}")
|
||||
|
|
|
|||
45
models.py
45
models.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Reference in a new issue