Initial prototype for theme song booth

This commit is contained in:
Troll (Hermes Agent) 2026-07-31 19:47:52 +00:00
commit c16d48ee32
16 changed files with 898 additions and 0 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
APP_SECRET_KEY=change-me-in-production
ADMIN_PASSWORD_HASH=
SMTP_HOST=mailroot8.namespro.ca
SMTP_PORT=465
SMTP_USER=ai@hallsworth.ca
SMTP_PASS=
SMTP_FROM=ai@hallsworth.ca
ADMIN_ALERT_EMAIL=ai@hallsworth.ca
PUBLIC_BASE_URL=http://127.0.0.1:5000
BOOTH_NAME=Trollgorithm Theme Songs
DATABASE=/app/data/booth.db
UPLOAD_FOLDER=/app/uploads
PRICE_PER_VERSION=10.00
CURRENCY=CAD

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.env
__pycache__/
*.pyc
.venv/
data/
uploads/
*.db
*.mp3
*.wav
.DS_Store

22
Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app
USER boothuser
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "--access-logfile", "-", "app:app"]

50
README.md Normal file
View file

@ -0,0 +1,50 @@
# Theme Song Booth
Prototype web app for a convention booth where attendees request custom AI-generated theme songs.
## Flow
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.
## Local Development
```bash
cd /home/jess/workspace/theme-song-booth
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env
# Edit .env and set APP_SECRET_KEY, ADMIN_PASSWORD_HASH, SMTP_PASS, PUBLIC_BASE_URL
.venv/bin/python init_db.py
.venv/bin/python -m flask --app app run --host=0.0.0.0
```
Generate an admin password hash with:
```bash
.venv/bin/python -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('yourpassword'))"
```
## Deployment on Unraid
1. Copy the project directory to your Unraid server or build it via git.
2. Create `/mnt/user/appdata/theme-song-booth/data` and `/mnt/user/appdata/theme-song-booth/uploads`.
3. Copy `.env.example` to `.env`, fill in real values, and place it next to `docker-compose.yml`.
4. Run `docker compose up -d --build`.
5. Initialize the database once: `docker compose exec booth python init_db.py`.
6. Point your chosen domain at the Unraid server's public IP and route it through your reverse proxy to `http://127.0.0.1:8000`.
7. Print the booth QR code pointing to `https://your-domain.example.com/request`.
## Files
- `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 Unraid.

285
app.py Normal file
View file

@ -0,0 +1,285 @@
import os
import smtplib
import ssl
from email.message import EmailMessage
from pathlib import Path
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
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
app = Flask(__name__)
app.config.from_object(Config)
app.teardown_appcontext(close_db)
STATUS_LABELS = {
'pending': 'Pending',
'prompt_ready': 'Prompt Ready',
'songs_uploaded': 'Songs Uploaded — Awaiting Approval',
'awaiting_payment': 'Awaiting Payment',
'paid': 'Paid',
'delivered': 'Delivered',
}
# ---------------- helpers ----------------
def is_admin():
return session.get('admin') is True
def require_admin():
if not is_admin():
return redirect(url_for('admin_login'))
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
def upload_path(request_id):
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):
if not file_obj or file_obj.filename == '':
return None
if not allowed_file(file_obj.filename):
flash('Only MP3 files are allowed.', 'error')
return None
p = upload_path(request_id)
filename = f'song_{version}.mp3'
file_obj.save(p / filename)
return str(p / filename)
def send_email(to, subject, body, attachments=None):
cfg = current_app.config
if not cfg['SMTP_PASS']:
raise RuntimeError('SMTP_PASS is not configured')
msg = EmailMessage()
msg['From'] = cfg['SMTP_FROM']
msg['To'] = to
msg['Subject'] = subject
msg.set_content(body)
if attachments:
for path, name in attachments:
with open(path, 'rb') as f:
data = f.read()
msg.add_attachment(data, maintype='audio', subtype='mpeg', filename=name)
with smtplib.SMTP_SSL(cfg['SMTP_HOST'], cfg['SMTP_PORT'], context=ssl.create_default_context()) as server:
server.login(cfg['SMTP_USER'], cfg['SMTP_PASS'])
server.send_message(msg)
# ---------------- public ----------------
@app.route('/')
def index():
return redirect(url_for('request_form'))
@app.route('/request', methods=['GET', 'POST'])
def request_form():
if request.method == 'POST':
rid = create_request(
name=request.form.get('name', '').strip(),
email=request.form.get('email', '').strip(),
hobbies=request.form.get('hobbies', '').strip(),
notable_facts=request.form.get('notable_facts', '').strip(),
style_genre=request.form.get('style_genre', '').strip(),
extra_requests=request.form.get('extra_requests', '').strip(),
)
flash('Your request has been submitted! Check your email soon.', 'success')
return redirect(url_for('thanks', rid=rid))
return render_template('request.html')
@app.route('/thanks/<int:rid>')
def thanks(rid):
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):
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):
req = get_request_by_token(token)
if not req:
abort(404)
choice = request.form.get('choice')
if choice not in ('a', 'b', 'both'):
flash('Invalid selection.', 'error')
return redirect(url_for('play', token=token))
update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc())
# alert operator
alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
if alert_to:
admin_link = f"{current_app.config['PUBLIC_BASE_URL']}/admin/request/{req['id']}"
version_label = {'a': 'A', 'b': 'B', 'both': 'Both'}[choice]
body = f"{req['name']} ({req['email']}) approved: Version {version_label}.\n\nRequest #{req['id']}\nPayment is now due.\n\nOpen admin: {admin_link}"
try:
send_email(alert_to, f"{req['name']} approved their theme song", body)
except Exception as e:
flash(f'Approval saved, but operator alert failed: {e}', 'warning')
return redirect(url_for('play', token=token))
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):
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')
alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
if alert_to and note:
admin_link = f"{current_app.config['PUBLIC_BASE_URL']}/admin/request/{req['id']}"
body = f"{req['name']} ({req['email']}) requested changes for request #{req['id']}.\n\nNote:\n{note}\n\nOpen admin: {admin_link}"
try:
send_email(alert_to, f"{req['name']} requested changes", body)
except Exception as e:
flash(f'Revision saved, but operator alert failed: {e}', 'warning')
return redirect(url_for('play', token=token))
flash('Your feedback has been sent. We will regenerate and update you.', 'success')
return redirect(url_for('play', token=token))
@app.route('/audio/<token>/<version>.mp3')
def audio(token, version):
req = get_request_by_token(token)
if not req:
abort(404)
if version not in ('a', 'b'):
abort(404)
field = f'song_{version}_path'
path = req.get(field)
if not path or not Path(path).exists():
abort(404)
return send_from_directory(Path(path).parent, Path(path).name)
# ---------------- admin ----------------
@app.route('/admin/login', methods=['GET', 'POST'])
def admin_login():
if is_admin():
return redirect(url_for('admin_dashboard'))
if request.method == 'POST':
pw_hash = current_app.config['ADMIN_PASSWORD_HASH']
if not pw_hash:
flash('Admin password is not configured.', 'error')
elif check_password_hash(pw_hash, request.form.get('password', '')):
session['admin'] = True
return redirect(url_for('admin_dashboard'))
else:
flash('Invalid password.', 'error')
return render_template('admin/login.html')
@app.route('/admin/logout')
def admin_logout():
session.pop('admin', None)
return redirect(url_for('admin_login'))
@app.route('/admin')
def admin_dashboard():
redir = require_admin()
if redir:
return redir
status_filter = request.args.get('status')
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):
redir = require_admin()
if redir:
return redir
req = get_request_by_id(rid)
if not req:
abort(404)
if request.method == 'POST':
action = request.form.get('action')
if action == 'save_prompt':
update_request(rid,
suno_style=request.form.get('suno_style', '').strip(),
suno_lyrics=request.form.get('suno_lyrics', '').strip(),
status='prompt_ready'
)
flash('Prompt saved.', 'success')
elif action == 'upload_songs':
a_path = save_upload(rid, request.files.get('song_a'), 'a')
b_path = save_upload(rid, request.files.get('song_b'), 'b')
fields = {}
if a_path:
fields['song_a_path'] = a_path
if b_path:
fields['song_b_path'] = b_path
if fields:
fields['status'] = 'songs_uploaded'
update_request(rid, **fields)
flash('Songs uploaded.', 'success')
elif action == 'notify_customer':
if not (req['song_a_path'] and req['song_b_path']):
flash('Both songs must be uploaded first.', 'error')
else:
player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}"
body = f"Hi {req['name']},\n\nYour custom theme song has been created. Listen to both versions and let us know which one you want:\n\n{player_link}\n\n- Version A\n- Version B\n- Or both versions\n\nOnce you make your choice, we'll send you to the booth to finalize payment and deliver your files.\n\nThanks for stopping by!\n\n{current_app.config['BOOTH_NAME']}"
try:
send_email(req['email'], 'Your custom theme song is ready — listen and pick your version', body)
update_request(rid, preview_sent_at=now_utc(), status='songs_uploaded')
flash('Preview email sent.', 'success')
except Exception as e:
flash(f'Failed to send preview email: {e}', 'error')
elif action == 'mark_paid_deliver':
if req['customer_approved'] == 'none':
flash('Customer has not approved a version yet.', 'error')
else:
payment_ref = request.form.get('square_payment_ref', '').strip()
if not payment_ref:
flash('Square payment reference is required.', 'error')
return redirect(url_for('admin_request', rid=rid))
attachments = []
if req['customer_approved'] in ('a', 'both') and req['song_a_path']:
attachments.append((req['song_a_path'], 'song_a.mp3'))
if req['customer_approved'] in ('b', 'both') and req['song_b_path']:
attachments.append((req['song_b_path'], 'song_b.mp3'))
player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}"
body = f"Hi {req['name']},\n\nThanks for your payment! Your approved song is attached to this email.\n\nIf you selected both versions, you'll find two MP3 files.\n\nYou can also keep streaming them here: {player_link}\n\nEnjoy!\n\n{current_app.config['BOOTH_NAME']}"
try:
send_email(req['email'], 'Your theme song files are here!', body, attachments=attachments)
update_request(rid, square_payment_ref=payment_ref, delivery_sent_at=now_utc(), status='delivered')
flash('Delivery email sent with MP3 attachments.', 'success')
except Exception as e:
flash(f'Failed to send delivery email: {e}', 'error')
return redirect(url_for('admin_request', rid=rid))
return render_template('admin/request.html', req=req, statuses=STATUS_LABELS)
# ---------------- init ----------------
@app.cli.command('init-db')
def init_db_command():
init_db()
print('Database initialized.')
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

26
config.py Normal file
View file

@ -0,0 +1,26 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me')
DATABASE = os.environ.get('DATABASE', '/app/data/booth.db')
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
ALLOWED_EXTENSIONS = {'mp3'}
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_PASSWORD_HASH = os.environ.get('ADMIN_PASSWORD_HASH', '')
ADMIN_ALERT_EMAIL = os.environ.get('ADMIN_ALERT_EMAIL', '')
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000')
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')
# Price settings (informational, for receipt page)
PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00'))
CURRENCY = os.environ.get('CURRENCY', 'CAD')

11
docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
services:
booth:
build: .
container_name: theme-song-booth
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:8000:8000"
volumes:
- /mnt/user/appdata/theme-song-booth/data:/app/data
- /mnt/user/appdata/theme-song-booth/uploads:/app/uploads

12
init_db.py Normal file
View file

@ -0,0 +1,12 @@
import os
import sys
# Ensure project root is importable
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import app
from models import init_db
with app.app_context():
init_db()
print(f"Database initialized at {app.config['DATABASE']}")

94
models.py Normal file
View file

@ -0,0 +1,94 @@
import sqlite3
import secrets
from datetime import datetime, timezone
from flask import current_app, g
SCHEMA = """
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
name TEXT NOT NULL,
email TEXT NOT NULL,
hobbies TEXT,
notable_facts TEXT,
style_genre TEXT,
extra_requests TEXT,
status TEXT DEFAULT 'pending',
suno_style TEXT,
suno_lyrics TEXT,
song_a_path TEXT,
song_b_path TEXT,
customer_approved TEXT DEFAULT 'none',
approval_notified_at TIMESTAMP,
preview_sent_at TIMESTAMP,
delivery_sent_at TIMESTAMP,
square_payment_ref TEXT,
admin_alert_email TEXT,
player_token TEXT NOT NULL UNIQUE,
revision_note TEXT
);
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():
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):
db = g.pop('db', None)
if db is not None:
db.close()
def init_db():
db = sqlite3.connect(current_app.config['DATABASE'])
db.executescript(SCHEMA)
db.commit()
db.close()
def new_token():
return secrets.token_urlsafe(32)
def now_utc():
return datetime.now(timezone.utc).isoformat()
def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests):
db = get_db()
cur = db.execute(
"""INSERT INTO requests
(name, email, hobbies, notable_facts, style_genre, extra_requests, player_token)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, email, hobbies, notable_facts, style_genre, extra_requests, new_token())
)
db.commit()
return cur.lastrowid
def get_request_by_id(request_id):
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):
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):
db = get_db()
if status:
rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall()
else:
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):
if not fields:
return
db = get_db()
cols = ', '.join(f'{k} = ?' for k in fields)
vals = list(fields.values()) + [request_id]
db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals)
db.commit()

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
flask
gunicorn
python-dotenv
werkzeug

View file

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<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}
.logout{float:right;color:#f87171;text-decoration:none}
.flash{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-bottom:1rem}
.flash.error{background:#450a0a}
</style>
</head>
<body>
<div class="container">
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
<h1>Theme Song Booth — Admin Dashboard</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<div class="filters">
<a href="{{ url_for('admin_dashboard') }}" class="{% if not current_status %}active{% endif %}">All</a>
{% for key,label in statuses.items() %}
<a href="{{ url_for('admin_dashboard', status=key) }}" class="{% if current_status == key %}active{% endif %}">{{ label }}</a>
{% endfor %}
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<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></td>
</tr>
{% endfor %}
{% if not requests %}
<tr><td colspan="7">No requests found.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</body>
</html>

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<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}
</style>
</head>
<body>
<form method="POST">
<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>
</html>

View file

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<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}
.copy-hint{font-size:.85rem;color:#9ca3af;margin-top:.3rem}
pre{background:#111827;padding:.8rem;border-radius:.5rem;overflow:auto;white-space:pre-wrap}
</style>
</head>
<body>
<div class="container">
<p><a href="{{ url_for('admin_dashboard') }}">← Dashboard</a></p>
<h1>Request #{{ req.id }} — {{ req.name }}</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<div class="section">
<h2>Customer Info</h2>
<div class="info-grid">
<div><strong>Email:</strong> {{ req.email }}</div>
<div><strong>Status:</strong> {{ statuses[req.status] }}</div>
</div>
<p><strong>Hobbies:</strong><br>{{ req.hobbies or '-' }}</p>
<p><strong>Notable facts:</strong><br>{{ req.notable_facts or '-' }}</p>
<p><strong>Style / genre:</strong><br>{{ req.style_genre or '-' }}</p>
<p><strong>Extra requests:</strong><br>{{ req.extra_requests or '-' }}</p>
</div>
<div class="section">
<h2>1. Generate Suno Prompt</h2>
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
<p class="copy-hint">Paste the result from Hermes into the fields below, then click Save.</p>
<form method="POST">
<input type="hidden" name="action" value="save_prompt">
<label for="suno_style">Suno Style</label>
<textarea id="suno_style" name="suno_style">{{ req.suno_style or '' }}</textarea>
<label for="suno_lyrics">Suno Lyrics (with metatags)</label>
<textarea id="suno_lyrics" name="suno_lyrics" rows="12">{{ req.suno_lyrics or '' }}</textarea>
<div class="actions">
<button type="submit">Save Prompt</button>
</div>
</form>
</div>
<div class="section">
<h2>2. Upload Songs</h2>
<form method="POST" enctype="multipart/form-data">
<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>
</div>
<div class="section">
<h2>3. Notify Customer</h2>
<p>Both songs must be uploaded first.</p>
<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">
<h2>4. Payment & Delivery</h2>
<p>Customer approved:
{% if req.customer_approved == 'none' %}
<span class="approved-box">Nothing yet</span>
{% else %}
<span class="approved-box">{{ req.customer_approved.upper() }}</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>
<script>
function copyPromptForHermes() {
const data = {
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. Return ONLY a JSON object with keys: title, style, lyrics.\n\nCustomer data:\n" + JSON.stringify(data, null, 2);
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes, then paste Hermes JSON result back into the Style/Lyrics fields."));
}
</script>
</div>
</body>
</html>

68
templates/player.html Normal file
View file

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<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}
</style>
</head>
<body>
<div class="container">
<h1>🎧 Your Custom Theme Song</h1>
<p>Hi {{ req.name }}! Listen to both versions and pick the one you want.</p>
<div class="player">
<h3>Version A</h3>
<audio controls src="{{ url_for('audio', token=req.player_token, version='a') }}"></audio>
</div>
<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'] %}
<form method="POST" action="{{ url_for('approve', token=req.player_token) }}">
<input type="hidden" name="choice" id="choice">
<div class="actions">
<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="{% if req.customer_approved == 'b' %}selected{% endif %}" onclick="document.getElementById('choice').value='b'">I want Version B</button>
<button type="submit" class="both {% if req.customer_approved == 'both' %}selected{% endif %}" onclick="document.getElementById('choice').value='both'">I want both</button>
</div>
</form>
<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>
<div class="actions">
<button type="submit" class="revision">Request Changes</button>
</div>
</form>
{% 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>
{% endif %}
{% if req.status == 'delivered' %}
<div class="status"><strong>Delivered! ✅</strong> Check your email for the MP3 attachment(s).</div>
{% endif %}
</div>
</body>
</html>

47
templates/request.html Normal file
View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request 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;box-shadow:0 10px 25px rgba(0,0,0,.3)}
h1{margin-top:0;color:#60a5fa}
label{display:block;margin-top:1rem;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:80px;resize:vertical}
button{margin-top:1.5rem;width:100%;padding:.9rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;font-size:1.05rem;cursor:pointer}
button:hover{background:#2563eb}
.note{margin-top:1rem;font-size:.9rem;color:#9ca3af}
</style>
</head>
<body>
<div class="container">
<h1>🎵 Get Your Custom Theme Song</h1>
<p>Tell us about yourself and we'll write a one-of-a-kind song for you.</p>
<form method="POST">
<label for="name">Name / persona you want in the song</label>
<input type="text" id="name" name="name" required>
<label for="email">Email address</label>
<input type="email" id="email" name="email" required>
<label for="hobbies">Hobbies & interests</label>
<textarea id="hobbies" name="hobbies" placeholder="e.g. rock climbing, retro gaming, sourdough baking"></textarea>
<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>
<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">
<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>
<button type="submit">Submit Request</button>
</form>
<p class="note">Your info is only used to create and deliver your song.</p>
</div>
</body>
</html>

22
templates/thanks.html Normal file
View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<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}
</style>
</head>
<body>
<div class="container">
<h1>✅ Request Received</h1>
<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 class="note">Bring this number to the booth if you want to check on progress.</p>
</div>
</body>
</html>