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
|
APP_SECRET_KEY=change-me-in-production
|
||||||
ADMIN_PASSWORD=change-me
|
ADMIN_PASSWORD=change-me
|
||||||
SMTP_HOST=mailroot8.namespro.ca
|
SMTP_HOST=mailroot8.namespro.ca
|
||||||
|
|
@ -8,9 +28,9 @@ SMTP_FROM=ai@hallsworth.ca
|
||||||
ADMIN_ALERT_EMAIL=
|
ADMIN_ALERT_EMAIL=
|
||||||
PUBLIC_BASE_URL=http://127.0.0.1:5000
|
PUBLIC_BASE_URL=http://127.0.0.1:5000
|
||||||
BOOTH_NAME=Trollgorithm Theme Songs
|
BOOTH_NAME=Trollgorithm Theme Songs
|
||||||
DATABASE=/app/data/booth.db
|
|
||||||
UPLOAD_FOLDER=/app/uploads
|
|
||||||
INTERNAL_PORT=8000
|
INTERNAL_PORT=8000
|
||||||
HOST_PORT=127.0.0.1:8000
|
HOST_PORT=127.0.0.1:8000
|
||||||
PRICE_PER_VERSION=10.00
|
PRICE_PER_VERSION=10.00
|
||||||
CURRENCY=CAD
|
CURRENCY=CAD
|
||||||
|
DATABASE=/app/data/booth.db
|
||||||
|
UPLOAD_FOLDER=/app/uploads
|
||||||
|
|
|
||||||
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
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install ffmpeg; clean apt cache to keep image small.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python requirements first for layer caching.
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application source, templates, static files, etc.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Avoid writing .pyc files and ensure stdout is unbuffered.
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Create a non-root user, persistent directories, and fix ownership.
|
||||||
RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app
|
RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app
|
||||||
USER boothuser
|
USER boothuser
|
||||||
|
|
||||||
# Default internal port; override with INTERNAL_PORT env var
|
# Default internal port; override with INTERNAL_PORT env var.
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Use shell form so environment variables are expanded at runtime
|
# Use shell form so environment variables are expanded at runtime.
|
||||||
CMD gunicorn -b 0.0.0.0:${INTERNAL_PORT:-8000} --access-logfile - app:app
|
CMD gunicorn -b 0.0.0.0:${INTERNAL_PORT:-8000} --access-logfile - app:app
|
||||||
|
|
|
||||||
132
README.md
132
README.md
|
|
@ -1,18 +1,54 @@
|
||||||
# Theme Song Booth
|
# Theme Song Booth
|
||||||
|
|
||||||
Prototype web app for a convention booth where attendees request custom AI-generated theme songs.
|
Custom theme-song request and delivery system for a convention booth. Customers fill out a form, the operator generates two AI-made song versions, the customer picks one, and the approved MP3 is delivered by email after payment is collected.
|
||||||
|
|
||||||
## Flow
|
## What this project does
|
||||||
|
|
||||||
1. Customer fills out the public request form at `/request`.
|
- **Customer request page** (`/request`) — booth visitors enter their name, email, hobbies, notable facts, preferred genre, and extra requests. A branded banner image is shown.
|
||||||
2. Operator generates a Suno Custom Mode prompt via Hermes and saves it in the admin detail page.
|
- **Operator dashboard** (`/admin`) — queue of all requests with status filters, per-request detail page, and system reset.
|
||||||
3. Operator generates two song versions in Suno and uploads the MP3s in admin.
|
- **Prompt generation** — the admin page builds a plain-text prompt for Hermes/AI, which returns a Title, Style, and Lyrics block. The operator pastes that response, clicks **Extract**, then uses Copy buttons to paste into Suno Custom Mode.
|
||||||
4. Operator clicks **Send Preview Link**. Customer receives an email with a private player page.
|
- **Song upload** — operator uploads Version A and Version B MP3s.
|
||||||
5. Customer listens to Version A and Version B, then approves one/both or requests changes.
|
- **Customer player page** — a private `/play/<token>` page emails to the customer. They can listen to both versions, choose A/B/both, or request changes.
|
||||||
6. Operator sees the approval alert, collects payment via Square reader, then clicks **Mark Paid & Deliver**.
|
- **Payment and delivery** — operator enters a Square payment reference and clicks **Mark Paid & Deliver**. The approved MP3(s) are emailed as attachments.
|
||||||
7. Customer receives the approved MP3(s) as email attachments.
|
- **System reset** — one button in the admin topbar clears all requests and files at the start of an event.
|
||||||
|
|
||||||
## Local Development
|
## Status flow
|
||||||
|
|
||||||
|
```
|
||||||
|
pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `pending` | Customer submitted request; no prompt yet. |
|
||||||
|
| `prompt_ready` | Operator saved Title/Style/Lyrics. |
|
||||||
|
| `songs_uploaded` | Both MP3s uploaded; preview link can be sent. |
|
||||||
|
| `awaiting_payment` | Customer approved a version. |
|
||||||
|
| `paid` | Payment reference recorded; delivery email sent. |
|
||||||
|
| `delivered` | MP3 attachments emailed. |
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app.py` | Flask routes, helpers, and email logic. |
|
||||||
|
| `config.py` | Environment-variable based configuration. |
|
||||||
|
| `models.py` | SQLite schema and database helper functions. |
|
||||||
|
| `init_db.py` | Standalone script to create the database tables. |
|
||||||
|
| `templates/request.html` | Customer request form (with banner). |
|
||||||
|
| `templates/thanks.html` | Post-submission confirmation. |
|
||||||
|
| `templates/player.html` | Customer audio player and approval page. |
|
||||||
|
| `templates/admin/login.html` | Admin password login. |
|
||||||
|
| `templates/admin/dashboard.html` | Operator queue with filters and reset. |
|
||||||
|
| `templates/admin/request.html` | Single-request detail / prompt / upload / delivery. |
|
||||||
|
| `static/Trollgorithm_booth.jpg` | Banner image on the request page. |
|
||||||
|
| `Dockerfile` | Production container image. |
|
||||||
|
| `docker-compose.yml` | Portainer stack definition. |
|
||||||
|
| `requirements.txt` | Python dependencies. |
|
||||||
|
| `.env.example` | Template for environment variables. |
|
||||||
|
| `REVIEW.md` | Quick reference for returning to this project. |
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/jess/workspace/theme-song-booth
|
cd /home/jess/workspace/theme-song-booth
|
||||||
|
|
@ -24,42 +60,62 @@ cp .env.example .env
|
||||||
.venv/bin/python -m flask --app app run --host=0.0.0.0
|
.venv/bin/python -m flask --app app run --host=0.0.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Visit:
|
||||||
|
- Customer form: http://127.0.0.1:5000/request
|
||||||
|
- Admin login: http://127.0.0.1:5000/admin
|
||||||
|
|
||||||
## Deployment with Portainer
|
## Deployment with Portainer
|
||||||
|
|
||||||
The repo includes a `docker-compose.yml` that builds directly from GitLab, so Portainer can pull and deploy it as a stack.
|
1. Log in to Portainer.
|
||||||
|
|
||||||
1. Log in to your Portainer instance.
|
|
||||||
2. Go to **Stacks** → **Add stack**.
|
2. Go to **Stacks** → **Add stack**.
|
||||||
3. Choose **Repository** and paste:
|
3. Choose **Repository**:
|
||||||
- URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git`
|
- URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git`
|
||||||
- Branch: `main`
|
- Branch: `main`
|
||||||
- Compose path: `docker-compose.yml`
|
- Compose path: `docker-compose.yml`
|
||||||
4. Add environment variables directly in Portainer under **Environment variables**:
|
4. Add environment variables:
|
||||||
- `APP_SECRET_KEY` — long random string
|
|
||||||
- `ADMIN_PASSWORD` — password for `/admin`
|
| Variable | Required | Purpose |
|
||||||
- `SMTP_PASS` — `b3ZzD@eM!MkqVS8P`
|
|---|---|---|
|
||||||
- `PUBLIC_BASE_URL` — your HTTPS domain
|
| `APP_SECRET_KEY` | Yes | Long random string for Flask sessions. Generate with `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
|
||||||
- `HOST_PORT` — default `127.0.0.1:8000`
|
| `ADMIN_PASSWORD` | Yes | Password for `/admin`. |
|
||||||
- `BOOTH_NAME` — optional
|
| `SMTP_PASS` | Yes | Password for `ai@hallsworth.ca`. |
|
||||||
|
| `PUBLIC_BASE_URL` | Yes | Public HTTPS URL, e.g. `https://booth.dionysismedia.ca`. |
|
||||||
|
| `HOST_PORT` | No | Host-side port mapping, default `127.0.0.1:8000`. |
|
||||||
|
| `INTERNAL_PORT` | No | Port gunicorn binds inside container, default `8000`. |
|
||||||
|
| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. |
|
||||||
|
| `PRICE_PER_VERSION` | No | Shown on receipt page, default `10.00`. |
|
||||||
|
| `CURRENCY` | No | Currency label, default `CAD`. |
|
||||||
|
|
||||||
5. Deploy the stack.
|
5. Deploy the stack.
|
||||||
6. Open a console into the `booth` container and run once:
|
6. Open a console in the `booth` container and run once:
|
||||||
```bash
|
|
||||||
python init_db.py
|
|
||||||
```
|
|
||||||
7. Point your reverse proxy at the `HOST_PORT` you chose (e.g. `http://host-ip:8000`).
|
|
||||||
8. Print the booth QR code pointing to `https://your-domain/request`.
|
|
||||||
|
|
||||||
### Portainer Notes
|
```bash
|
||||||
|
python init_db.py
|
||||||
|
```
|
||||||
|
|
||||||
- The `docker-compose.yml` uses named volumes (`booth-data`, `booth-uploads`) so Portainer handles persistence automatically.
|
7. Point your reverse proxy at the `HOST_PORT` you chose.
|
||||||
- For a pre-built image instead of repo build, replace the `build:` block with an `image:` line pointing to your registry.
|
8. Print or display a QR code pointing to `https://your-domain/request`.
|
||||||
- Update the stack after each push to redeploy the latest code.
|
|
||||||
|
|
||||||
## Files
|
### Updating the deployment
|
||||||
|
|
||||||
- `app.py` — Flask application with public/admin routes and email logic.
|
After each push to GitLab, go to Portainer → **Stacks** → `theme-song-booth` → **Pull and redeploy** to rebuild from the repo.
|
||||||
- `models.py` — SQLite schema and helper functions.
|
|
||||||
- `config.py` — Configuration loaded from environment.
|
## Important notes
|
||||||
- `templates/` — Jinja2 HTML templates.
|
|
||||||
- `init_db.py` — Standalone script to create the SQLite database.
|
- **No `.env` file in production.** `docker-compose.yml` passes variables directly from Portainer. This avoids Portainer's `env_file not found` error.
|
||||||
- `Dockerfile` / `docker-compose.yml` — Container packaging for Portainer.
|
- **Payments are manual.** The app records a Square payment reference but does not integrate with Square's API. Use a Square Terminal/Reader at the booth.
|
||||||
|
- **Operator queue is the dashboard.** No operator email alerts are sent; approvals and revision notes appear as status changes in `/admin`.
|
||||||
|
- **Security:** the repo is public on GitLab. No secrets are committed. Admin password is plain text in the Portainer environment.
|
||||||
|
|
||||||
|
## Common troubleshooting
|
||||||
|
|
||||||
|
| Problem | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| "Send Preview Link" does nothing | Form tags were unbalanced (now fixed). | Redeploy the latest commit. |
|
||||||
|
| Emails not arriving | SMTP_PASS wrong or messages in spam. | Verify SMTP credentials; check spam folder. |
|
||||||
|
| Can't reach app through domain | Reverse proxy points to wrong host port. | Match `HOST_PORT` to your proxy upstream. |
|
||||||
|
| Static banner not showing | Browser cached old image. | Hard-refresh or redeploy stack. |
|
||||||
|
|
||||||
|
## License / ownership
|
||||||
|
|
||||||
|
Built for Jess's Trollgorithm theme-song booth. All code and assets are private to that project.
|
||||||
|
|
|
||||||
94
REVIEW.md
Normal file
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 os
|
||||||
import shutil
|
import shutil
|
||||||
import smtplib
|
import smtplib
|
||||||
|
|
@ -5,16 +32,26 @@ import ssl
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Flask and related imports
|
||||||
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app
|
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
# Project imports
|
||||||
from config import Config
|
from config import Config
|
||||||
from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests
|
from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Create the Flask app and load configuration from Config class.
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
# Ensure the SQLite connection is closed at the end of each request.
|
||||||
app.teardown_appcontext(close_db)
|
app.teardown_appcontext(close_db)
|
||||||
|
|
||||||
|
# Human-readable labels for each status value stored in the database.
|
||||||
STATUS_LABELS = {
|
STATUS_LABELS = {
|
||||||
'pending': 'Pending',
|
'pending': 'Pending',
|
||||||
'prompt_ready': 'Prompt Ready',
|
'prompt_ready': 'Prompt Ready',
|
||||||
|
|
@ -24,27 +61,46 @@ STATUS_LABELS = {
|
||||||
'delivered': 'Delivered',
|
'delivered': 'Delivered',
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------- helpers ----------------
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def is_admin():
|
def is_admin():
|
||||||
|
"""Return True if the current browser session is logged in as admin."""
|
||||||
return session.get('admin') is True
|
return session.get('admin') is True
|
||||||
|
|
||||||
|
|
||||||
def require_admin():
|
def require_admin():
|
||||||
|
"""Redirect to the admin login page if the user is not logged in."""
|
||||||
if not is_admin():
|
if not is_admin():
|
||||||
return redirect(url_for('admin_login'))
|
return redirect(url_for('admin_login'))
|
||||||
|
|
||||||
|
|
||||||
def admin_password_ok(pw):
|
def admin_password_ok(pw):
|
||||||
|
"""Check the submitted admin password against the configured one."""
|
||||||
return pw and pw == current_app.config['ADMIN_PASSWORD']
|
return pw and pw == current_app.config['ADMIN_PASSWORD']
|
||||||
|
|
||||||
|
|
||||||
def allowed_file(filename):
|
def allowed_file(filename):
|
||||||
|
"""Return True if the uploaded filename has an allowed extension (mp3)."""
|
||||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
|
||||||
|
|
||||||
|
|
||||||
def upload_path(request_id):
|
def upload_path(request_id):
|
||||||
|
"""Return the per-request upload directory path, creating it if necessary."""
|
||||||
p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id)
|
p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id)
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
def save_upload(request_id, file_obj, version):
|
def save_upload(request_id, file_obj, version):
|
||||||
|
"""
|
||||||
|
Save an uploaded MP3 file for a request.
|
||||||
|
:param request_id: database ID of the request
|
||||||
|
:param file_obj: Flask FileStorage from request.files
|
||||||
|
:param version: 'a' or 'b'
|
||||||
|
:return: full filesystem path saved, or None on missing/invalid file
|
||||||
|
"""
|
||||||
if not file_obj or file_obj.filename == '':
|
if not file_obj or file_obj.filename == '':
|
||||||
return None
|
return None
|
||||||
if not allowed_file(file_obj.filename):
|
if not allowed_file(file_obj.filename):
|
||||||
|
|
@ -55,7 +111,15 @@ def save_upload(request_id, file_obj, version):
|
||||||
file_obj.save(p / filename)
|
file_obj.save(p / filename)
|
||||||
return str(p / filename)
|
return str(p / filename)
|
||||||
|
|
||||||
|
|
||||||
def send_email(to, subject, body, attachments=None):
|
def send_email(to, subject, body, attachments=None):
|
||||||
|
"""
|
||||||
|
Send an email via SMTP_SSL.
|
||||||
|
:param to: recipient address
|
||||||
|
:param subject: email subject
|
||||||
|
:param body: plain-text body
|
||||||
|
:param attachments: optional list of (filepath, attachment_name) tuples
|
||||||
|
"""
|
||||||
cfg = current_app.config
|
cfg = current_app.config
|
||||||
if not cfg['SMTP_PASS']:
|
if not cfg['SMTP_PASS']:
|
||||||
raise RuntimeError('SMTP_PASS is not configured')
|
raise RuntimeError('SMTP_PASS is not configured')
|
||||||
|
|
@ -66,6 +130,7 @@ def send_email(to, subject, body, attachments=None):
|
||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg.set_content(body)
|
msg.set_content(body)
|
||||||
|
|
||||||
|
# Attach any MP3 files as audio/mpeg attachments.
|
||||||
if attachments:
|
if attachments:
|
||||||
for path, name in attachments:
|
for path, name in attachments:
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
|
|
@ -76,14 +141,24 @@ def send_email(to, subject, body, attachments=None):
|
||||||
server.login(cfg['SMTP_USER'], cfg['SMTP_PASS'])
|
server.login(cfg['SMTP_USER'], cfg['SMTP_PASS'])
|
||||||
server.send_message(msg)
|
server.send_message(msg)
|
||||||
|
|
||||||
# ---------------- public ----------------
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public customer routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
|
"""Root route: redirect customers straight to the request form."""
|
||||||
return redirect(url_for('request_form'))
|
return redirect(url_for('request_form'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/request', methods=['GET', 'POST'])
|
@app.route('/request', methods=['GET', 'POST'])
|
||||||
def request_form():
|
def request_form():
|
||||||
|
"""
|
||||||
|
Public request form.
|
||||||
|
GET -> shows the form with the banner image.
|
||||||
|
POST -> creates a database record and redirects to the thanks page.
|
||||||
|
"""
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
rid = create_request(
|
rid = create_request(
|
||||||
name=request.form.get('name', '').strip(),
|
name=request.form.get('name', '').strip(),
|
||||||
|
|
@ -97,22 +172,34 @@ def request_form():
|
||||||
return redirect(url_for('thanks', rid=rid))
|
return redirect(url_for('thanks', rid=rid))
|
||||||
return render_template('request.html')
|
return render_template('request.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/thanks/<int:rid>')
|
@app.route('/thanks/<int:rid>')
|
||||||
def thanks(rid):
|
def thanks(rid):
|
||||||
|
"""Confirmation page shown after a customer submits a request."""
|
||||||
req = get_request_by_id(rid)
|
req = get_request_by_id(rid)
|
||||||
if not req:
|
if not req:
|
||||||
abort(404)
|
abort(404)
|
||||||
return render_template('thanks.html', req=req)
|
return render_template('thanks.html', req=req)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/play/<token>')
|
@app.route('/play/<token>')
|
||||||
def play(token):
|
def play(token):
|
||||||
|
"""
|
||||||
|
Private player page for a customer.
|
||||||
|
The token is a cryptographically random URL-safe string generated at request time.
|
||||||
|
"""
|
||||||
req = get_request_by_token(token)
|
req = get_request_by_token(token)
|
||||||
if not req:
|
if not req:
|
||||||
abort(404)
|
abort(404)
|
||||||
return render_template('player.html', req=req)
|
return render_template('player.html', req=req)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/play/<token>/approve', methods=['POST'])
|
@app.route('/play/<token>/approve', methods=['POST'])
|
||||||
def approve(token):
|
def approve(token):
|
||||||
|
"""
|
||||||
|
Customer has chosen Version A, Version B, or both.
|
||||||
|
Updates the request status to 'awaiting_payment' so the operator can collect payment.
|
||||||
|
"""
|
||||||
req = get_request_by_token(token)
|
req = get_request_by_token(token)
|
||||||
if not req:
|
if not req:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
@ -123,28 +210,37 @@ def approve(token):
|
||||||
|
|
||||||
update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc())
|
update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc())
|
||||||
|
|
||||||
# alert operator (disabled — admin dashboard is the queue)
|
# NOTE: Operator email alerts are intentionally disabled. The admin dashboard is the single queue.
|
||||||
# alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
|
# alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
|
||||||
# if alert_to: ...
|
# if alert_to: ...
|
||||||
|
|
||||||
flash('Thanks! Please return to the booth to finalize payment.', 'success')
|
flash('Thanks! Please return to the booth to finalize payment.', 'success')
|
||||||
return redirect(url_for('play', token=token))
|
return redirect(url_for('play', token=token))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/play/<token>/revise', methods=['POST'])
|
@app.route('/play/<token>/revise', methods=['POST'])
|
||||||
def revise(token):
|
def revise(token):
|
||||||
|
"""
|
||||||
|
Customer asked for changes. Store the note and reset status to 'songs_uploaded'
|
||||||
|
so the operator sees it in the dashboard queue.
|
||||||
|
"""
|
||||||
req = get_request_by_token(token)
|
req = get_request_by_token(token)
|
||||||
if not req:
|
if not req:
|
||||||
abort(404)
|
abort(404)
|
||||||
note = request.form.get('revision_note', '').strip()
|
note = request.form.get('revision_note', '').strip()
|
||||||
update_request(req['id'], revision_note=note, status='songs_uploaded')
|
update_request(req['id'], revision_note=note, status='songs_uploaded')
|
||||||
|
|
||||||
# Revision feedback is stored in the DB and surfaced on the admin dashboard.
|
# NOTE: No operator email is sent; the dashboard is the single queue.
|
||||||
# No operator email is sent — the dashboard is the single queue.
|
|
||||||
flash('Your feedback has been saved. We will regenerate and update you.', 'success')
|
flash('Your feedback has been saved. We will regenerate and update you.', 'success')
|
||||||
return redirect(url_for('play', token=token))
|
return redirect(url_for('play', token=token))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/audio/<token>/<version>.mp3')
|
@app.route('/audio/<token>/<version>.mp3')
|
||||||
def audio(token, version):
|
def audio(token, version):
|
||||||
|
"""
|
||||||
|
Serve an uploaded MP3 file for a specific request token and version ('a' or 'b').
|
||||||
|
This keeps the files off the public static path and ties them to the private token.
|
||||||
|
"""
|
||||||
req = get_request_by_token(token)
|
req = get_request_by_token(token)
|
||||||
if not req:
|
if not req:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
@ -156,10 +252,14 @@ def audio(token, version):
|
||||||
abort(404)
|
abort(404)
|
||||||
return send_from_directory(Path(path).parent, Path(path).name)
|
return send_from_directory(Path(path).parent, Path(path).name)
|
||||||
|
|
||||||
# ---------------- admin ----------------
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.route('/admin/login', methods=['GET', 'POST'])
|
@app.route('/admin/login', methods=['GET', 'POST'])
|
||||||
def admin_login():
|
def admin_login():
|
||||||
|
"""Simple session-based admin login. Password is set via ADMIN_PASSWORD env var."""
|
||||||
if is_admin():
|
if is_admin():
|
||||||
return redirect(url_for('admin_dashboard'))
|
return redirect(url_for('admin_dashboard'))
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
|
@ -169,13 +269,20 @@ def admin_login():
|
||||||
flash('Invalid password.', 'error')
|
flash('Invalid password.', 'error')
|
||||||
return render_template('admin/login.html')
|
return render_template('admin/login.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/logout')
|
@app.route('/admin/logout')
|
||||||
def admin_logout():
|
def admin_logout():
|
||||||
|
"""Clear the admin session."""
|
||||||
session.pop('admin', None)
|
session.pop('admin', None)
|
||||||
return redirect(url_for('admin_login'))
|
return redirect(url_for('admin_login'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin')
|
@app.route('/admin')
|
||||||
def admin_dashboard():
|
def admin_dashboard():
|
||||||
|
"""
|
||||||
|
Main operator queue.
|
||||||
|
Optional ?status= filter lets operators focus on one state at a time.
|
||||||
|
"""
|
||||||
redir = require_admin()
|
redir = require_admin()
|
||||||
if redir:
|
if redir:
|
||||||
return redir
|
return redir
|
||||||
|
|
@ -183,8 +290,15 @@ def admin_dashboard():
|
||||||
requests = list_requests(status_filter)
|
requests = list_requests(status_filter)
|
||||||
return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=status_filter)
|
return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=status_filter)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/request/<int:rid>', methods=['GET', 'POST'])
|
@app.route('/admin/request/<int:rid>', methods=['GET', 'POST'])
|
||||||
def admin_request(rid):
|
def admin_request(rid):
|
||||||
|
"""
|
||||||
|
Detail/edit page for a single request.
|
||||||
|
GET -> render the request details and editing forms.
|
||||||
|
POST -> handle one of four actions:
|
||||||
|
save_prompt, upload_songs, notify_customer, mark_paid_deliver
|
||||||
|
"""
|
||||||
redir = require_admin()
|
redir = require_admin()
|
||||||
if redir:
|
if redir:
|
||||||
return redir
|
return redir
|
||||||
|
|
@ -192,6 +306,7 @@ def admin_request(rid):
|
||||||
if not req:
|
if not req:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
# Small helpers exposed to the template for status badges.
|
||||||
def file_exists(path):
|
def file_exists(path):
|
||||||
return bool(path and Path(path).exists())
|
return bool(path and Path(path).exists())
|
||||||
|
|
||||||
|
|
@ -202,6 +317,7 @@ def admin_request(rid):
|
||||||
action = request.form.get('action')
|
action = request.form.get('action')
|
||||||
|
|
||||||
if action == 'save_prompt':
|
if action == 'save_prompt':
|
||||||
|
# Store the generated title/style/lyrics and mark prompt ready.
|
||||||
update_request(rid,
|
update_request(rid,
|
||||||
suno_title=request.form.get('suno_title', '').strip(),
|
suno_title=request.form.get('suno_title', '').strip(),
|
||||||
suno_style=request.form.get('suno_style', '').strip(),
|
suno_style=request.form.get('suno_style', '').strip(),
|
||||||
|
|
@ -211,6 +327,7 @@ def admin_request(rid):
|
||||||
flash('Prompt saved.', 'success')
|
flash('Prompt saved.', 'success')
|
||||||
|
|
||||||
elif action == 'upload_songs':
|
elif action == 'upload_songs':
|
||||||
|
# Save uploaded MP3 files for Version A and/or Version B.
|
||||||
a_path = save_upload(rid, request.files.get('song_a'), 'a')
|
a_path = save_upload(rid, request.files.get('song_a'), 'a')
|
||||||
b_path = save_upload(rid, request.files.get('song_b'), 'b')
|
b_path = save_upload(rid, request.files.get('song_b'), 'b')
|
||||||
fields = {}
|
fields = {}
|
||||||
|
|
@ -224,6 +341,7 @@ def admin_request(rid):
|
||||||
flash('Songs uploaded.', 'success')
|
flash('Songs uploaded.', 'success')
|
||||||
|
|
||||||
elif action == 'notify_customer':
|
elif action == 'notify_customer':
|
||||||
|
# Email the customer a private player link. Both songs must be uploaded first.
|
||||||
if not (req['song_a_path'] and req['song_b_path']):
|
if not (req['song_a_path'] and req['song_b_path']):
|
||||||
flash('Both songs must be uploaded first.', 'error')
|
flash('Both songs must be uploaded first.', 'error')
|
||||||
else:
|
else:
|
||||||
|
|
@ -237,6 +355,7 @@ def admin_request(rid):
|
||||||
flash(f'Failed to send preview email: {e}', 'error')
|
flash(f'Failed to send preview email: {e}', 'error')
|
||||||
|
|
||||||
elif action == 'mark_paid_deliver':
|
elif action == 'mark_paid_deliver':
|
||||||
|
# Finalize: record Square payment ref, attach approved MP3s, email customer.
|
||||||
if req['customer_approved'] == 'none':
|
if req['customer_approved'] == 'none':
|
||||||
flash('Customer has not approved a version yet.', 'error')
|
flash('Customer has not approved a version yet.', 'error')
|
||||||
else:
|
else:
|
||||||
|
|
@ -264,8 +383,10 @@ def admin_request(rid):
|
||||||
|
|
||||||
return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename)
|
return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/request/<int:rid>/delete', methods=['POST'])
|
@app.route('/admin/request/<int:rid>/delete', methods=['POST'])
|
||||||
def admin_delete_request(rid):
|
def admin_delete_request(rid):
|
||||||
|
"""Delete a single request and remove its uploaded MP3 files."""
|
||||||
redir = require_admin()
|
redir = require_admin()
|
||||||
if redir:
|
if redir:
|
||||||
return redir
|
return redir
|
||||||
|
|
@ -273,7 +394,7 @@ def admin_delete_request(rid):
|
||||||
if not req:
|
if not req:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
# Delete uploaded files if they exist
|
# Delete uploaded files if they exist.
|
||||||
for field in ('song_a_path', 'song_b_path'):
|
for field in ('song_a_path', 'song_b_path'):
|
||||||
path = req.get(field)
|
path = req.get(field)
|
||||||
if path and Path(path).exists():
|
if path and Path(path).exists():
|
||||||
|
|
@ -281,7 +402,7 @@ def admin_delete_request(rid):
|
||||||
Path(path).unlink()
|
Path(path).unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
# Remove empty upload directory
|
# Remove empty upload directory.
|
||||||
upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid)
|
upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid)
|
||||||
if upload_dir.exists():
|
if upload_dir.exists():
|
||||||
try:
|
try:
|
||||||
|
|
@ -293,8 +414,14 @@ def admin_delete_request(rid):
|
||||||
flash(f'Request #{rid} deleted.', 'success')
|
flash(f'Request #{rid} deleted.', 'success')
|
||||||
return redirect(url_for('admin_dashboard'))
|
return redirect(url_for('admin_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/reset', methods=['POST'])
|
@app.route('/admin/reset', methods=['POST'])
|
||||||
def admin_reset_system():
|
def admin_reset_system():
|
||||||
|
"""
|
||||||
|
Nuclear reset for the start of an event.
|
||||||
|
Deletes all database rows and all files/directories under UPLOAD_FOLDER.
|
||||||
|
Requires clicking through a browser confirm dialog.
|
||||||
|
"""
|
||||||
redir = require_admin()
|
redir = require_admin()
|
||||||
if redir:
|
if redir:
|
||||||
return redir
|
return redir
|
||||||
|
|
@ -314,12 +441,18 @@ def admin_reset_system():
|
||||||
flash('System reset complete. All orders and files have been cleared.', 'success')
|
flash('System reset complete. All orders and files have been cleared.', 'success')
|
||||||
return redirect(url_for('admin_dashboard'))
|
return redirect(url_for('admin_dashboard'))
|
||||||
|
|
||||||
# ---------------- init ----------------
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI and entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.cli.command('init-db')
|
@app.cli.command('init-db')
|
||||||
def init_db_command():
|
def init_db_command():
|
||||||
|
"""Flask CLI command: flask --app app init-db"""
|
||||||
init_db()
|
init_db()
|
||||||
print('Database initialized.')
|
print('Database initialized.')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
# Development-only entry point. Production uses gunicorn (see Dockerfile).
|
||||||
app.run(debug=True, host='0.0.0.0')
|
app.run(debug=True, host='0.0.0.0')
|
||||||
|
|
|
||||||
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
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load variables from .env file if present (development mode).
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
# Flask secret key: used to sign session cookies. Must be a long random string in production.
|
||||||
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me')
|
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me')
|
||||||
|
|
||||||
|
# SQLite database path inside the container.
|
||||||
DATABASE = os.environ.get('DATABASE', '/app/data/booth.db')
|
DATABASE = os.environ.get('DATABASE', '/app/data/booth.db')
|
||||||
|
|
||||||
|
# Directory where uploaded MP3 files are stored inside the container.
|
||||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
|
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
|
||||||
|
|
||||||
|
# Only MP3 uploads are allowed.
|
||||||
ALLOWED_EXTENSIONS = {'mp3'}
|
ALLOWED_EXTENSIONS = {'mp3'}
|
||||||
|
|
||||||
|
# SMTP server settings for sending customer emails.
|
||||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca')
|
SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca')
|
||||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))
|
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))
|
||||||
SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca')
|
SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca')
|
||||||
SMTP_PASS = os.environ.get('SMTP_PASS', '')
|
SMTP_PASS = os.environ.get('SMTP_PASS', '')
|
||||||
SMTP_FROM = os.environ.get('SMTP_FROM', 'ai@hallsworth.ca')
|
SMTP_FROM = os.environ.get('SMTP_FROM', 'ai@hallsworth.ca')
|
||||||
|
|
||||||
|
# Admin login password (plain text, set via env).
|
||||||
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '')
|
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '')
|
||||||
|
|
||||||
|
# Optional operator alert email. Currently unused because the dashboard is the queue.
|
||||||
ADMIN_ALERT_EMAIL = os.environ.get('ADMIN_ALERT_EMAIL', '')
|
ADMIN_ALERT_EMAIL = os.environ.get('ADMIN_ALERT_EMAIL', '')
|
||||||
|
|
||||||
|
# Public HTTPS URL used in customer emails and QR codes.
|
||||||
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000')
|
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000')
|
||||||
|
|
||||||
|
# Booth name used in email sign-offs.
|
||||||
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')
|
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')
|
||||||
|
|
||||||
# Port the container listens on internally (gunicorn)
|
# Internal port gunicorn listens on inside the container.
|
||||||
INTERNAL_PORT = int(os.environ.get('INTERNAL_PORT', '8000'))
|
INTERNAL_PORT = int(os.environ.get('INTERNAL_PORT', '8000'))
|
||||||
|
|
||||||
# Price settings (informational, for receipt page)
|
# Price per version shown on the receipt page (informational only; payment is manual).
|
||||||
PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00'))
|
PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00'))
|
||||||
CURRENCY = os.environ.get('CURRENCY', 'CAD')
|
CURRENCY = os.environ.get('CURRENCY', 'CAD')
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,16 @@
|
||||||
|
# docker-compose.yml
|
||||||
|
# ==================
|
||||||
|
#
|
||||||
|
# Portainer stack definition.
|
||||||
|
# Builds the image directly from the GitLab repository (main branch).
|
||||||
|
#
|
||||||
|
# Environment variables are set in Portainer under the stack's
|
||||||
|
# Environment variables section. The container uses them directly,
|
||||||
|
# so no .env file is required on disk.
|
||||||
|
#
|
||||||
|
# Named volumes keep the SQLite database and uploaded MP3s persistent
|
||||||
|
# across container restarts and redeploys.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
booth:
|
booth:
|
||||||
build:
|
build:
|
||||||
|
|
@ -12,10 +25,13 @@ services:
|
||||||
- SMTP_USER=${SMTP_USER:-ai@hallsworth.ca}
|
- SMTP_USER=${SMTP_USER:-ai@hallsworth.ca}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- SMTP_FROM=${SMTP_FROM:-ai@hallsworth.ca}
|
- SMTP_FROM=${SMTP_FROM:-ai@hallsworth.ca}
|
||||||
- ADMIN_ALERT_EMAIL=${ADMIN_ALERT_EMAIL:-}
|
|
||||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL}
|
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL}
|
||||||
- BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs}
|
- BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs}
|
||||||
- INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
- INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
||||||
|
- PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00}
|
||||||
|
- CURRENCY=${CURRENCY:-CAD}
|
||||||
|
- DATABASE=${DATABASE:-/app/data/booth.db}
|
||||||
|
- UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads}
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-127.0.0.1:8000}:${INTERNAL_PORT:-8000}"
|
- "${HOST_PORT:-127.0.0.1:8000}:${INTERNAL_PORT:-8000}"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
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 os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Ensure project root is importable
|
# Ensure the project root is on sys.path so `from app import app` works.
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from models import init_db
|
from models import init_db
|
||||||
|
|
||||||
|
# Use the configured database path and create tables.
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
init_db()
|
init_db()
|
||||||
print(f"Database initialized at {app.config['DATABASE']}")
|
print(f"Database initialized at {app.config['DATABASE']}")
|
||||||
|
|
|
||||||
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 sqlite3
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask import current_app, g
|
from flask import current_app, g
|
||||||
|
|
||||||
|
# SQL executed by init_db() to create the requests table and indexes.
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS requests (
|
CREATE TABLE IF NOT EXISTS requests (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -33,30 +48,45 @@ CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token);
|
CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
|
"""Get or create a SQLite connection tied to the current Flask request context."""
|
||||||
if 'db' not in g:
|
if 'db' not in g:
|
||||||
g.db = sqlite3.connect(current_app.config['DATABASE'])
|
g.db = sqlite3.connect(current_app.config['DATABASE'])
|
||||||
g.db.row_factory = sqlite3.Row
|
g.db.row_factory = sqlite3.Row
|
||||||
return g.db
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
def close_db(e=None):
|
def close_db(e=None):
|
||||||
|
"""Close the request-scoped SQLite connection. Registered as teardown handler."""
|
||||||
db = g.pop('db', None)
|
db = g.pop('db', None)
|
||||||
if db is not None:
|
if db is not None:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
|
"""Create the database file and tables. Safe to run multiple times."""
|
||||||
db = sqlite3.connect(current_app.config['DATABASE'])
|
db = sqlite3.connect(current_app.config['DATABASE'])
|
||||||
db.executescript(SCHEMA)
|
db.executescript(SCHEMA)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def new_token():
|
def new_token():
|
||||||
|
"""Generate a URL-safe random token used for private player links."""
|
||||||
return secrets.token_urlsafe(32)
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
def now_utc():
|
def now_utc():
|
||||||
|
"""Return current UTC time as ISO-8601 string for timestamp columns."""
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests):
|
def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests):
|
||||||
|
"""
|
||||||
|
Insert a new customer request.
|
||||||
|
Returns the auto-generated request id.
|
||||||
|
"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
cur = db.execute(
|
cur = db.execute(
|
||||||
"""INSERT INTO requests
|
"""INSERT INTO requests
|
||||||
|
|
@ -67,17 +97,23 @@ def create_request(name, email, hobbies, notable_facts, style_genre, extra_reque
|
||||||
db.commit()
|
db.commit()
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
def get_request_by_id(request_id):
|
def get_request_by_id(request_id):
|
||||||
|
"""Fetch one request by numeric id. Returns dict or None."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
row = db.execute('SELECT * FROM requests WHERE id = ?', (request_id,)).fetchone()
|
row = db.execute('SELECT * FROM requests WHERE id = ?', (request_id,)).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
def get_request_by_token(token):
|
def get_request_by_token(token):
|
||||||
|
"""Fetch one request by its private player token. Returns dict or None."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
row = db.execute('SELECT * FROM requests WHERE player_token = ?', (token,)).fetchone()
|
row = db.execute('SELECT * FROM requests WHERE player_token = ?', (token,)).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
def list_requests(status=None):
|
def list_requests(status=None):
|
||||||
|
"""List all requests, optionally filtered by status, newest first."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
if status:
|
if status:
|
||||||
rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall()
|
rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall()
|
||||||
|
|
@ -85,7 +121,12 @@ def list_requests(status=None):
|
||||||
rows = db.execute('SELECT * FROM requests ORDER BY created_at DESC').fetchall()
|
rows = db.execute('SELECT * FROM requests ORDER BY created_at DESC').fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def update_request(request_id, **fields):
|
def update_request(request_id, **fields):
|
||||||
|
"""
|
||||||
|
Update arbitrary columns for a request.
|
||||||
|
Example: update_request(1, status='prompt_ready', suno_style='...')
|
||||||
|
"""
|
||||||
if not fields:
|
if not fields:
|
||||||
return
|
return
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
|
@ -94,12 +135,16 @@ def update_request(request_id, **fields):
|
||||||
db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals)
|
db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def delete_request(request_id):
|
def delete_request(request_id):
|
||||||
|
"""Delete a single request by id. Does NOT delete associated files."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
db.execute('DELETE FROM requests WHERE id = ?', (request_id,))
|
db.execute('DELETE FROM requests WHERE id = ?', (request_id,))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def reset_all_requests():
|
def reset_all_requests():
|
||||||
|
"""Delete every row in the requests table. Does NOT delete files."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
db.execute('DELETE FROM requests')
|
db.execute('DELETE FROM requests')
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,13 @@
|
||||||
|
# requirements.txt
|
||||||
|
# ================
|
||||||
|
#
|
||||||
|
# Python dependencies for the Theme Song Booth Flask app.
|
||||||
|
#
|
||||||
|
# flask - web framework
|
||||||
|
# gunicorn - production WSGI server used by Dockerfile
|
||||||
|
# python-dotenv - loads .env files in development
|
||||||
|
# werkzeug - utilities for file uploads and password hashing
|
||||||
|
|
||||||
flask
|
flask
|
||||||
gunicorn
|
gunicorn
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,163 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Admin Dashboard</title>
|
<title>Admin Dashboard</title>
|
||||||
<style>
|
<style>
|
||||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
|
/*
|
||||||
.container{max-width:1100px;margin:0 auto}
|
Operator dashboard queue.
|
||||||
h1{color:#60a5fa}
|
Shows all requests in a table, with status filters,
|
||||||
.filters{margin-bottom:1rem}
|
per-row Open/Delete actions, and a topbar Reset System button.
|
||||||
.filters a{color:#93c5fd;text-decoration:none;margin-right:1rem}
|
*/
|
||||||
.filters a.active{font-weight:bold;color:#fff}
|
body{
|
||||||
table{width:100%;border-collapse:collapse;background:#1f2937;border-radius:.5rem;overflow:hidden}
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
th,td{padding:.7rem;text-align:left;border-bottom:1px solid #374151}
|
background:#111827;
|
||||||
th{background:#111827;color:#9ca3af}
|
color:#f3f4f6;
|
||||||
tr:hover{background:#2d3748}
|
margin:0;
|
||||||
.status-badge{display:inline-block;padding:.25rem .6rem;border-radius:9999px;font-size:.8rem;font-weight:600;background:#374151}
|
padding:1rem;
|
||||||
.awaiting_payment{background:#f59e0b;color:#000}
|
line-height:1.5;
|
||||||
.paid,.delivered{background:#10b981;color:#000}
|
}
|
||||||
.pending,.prompt_ready{background:#60a5fa;color:#000}
|
.container{max-width:1100px;margin:0 auto;}
|
||||||
.songs_uploaded{background:#a78bfa;color:#000}
|
h1{color:#60a5fa;}
|
||||||
.actions a{color:#93c5fd;text-decoration:none;margin-right:.8rem}
|
.filters{margin-bottom:1rem;}
|
||||||
.actions button.delete{background:transparent;color:#f87171;border:none;padding:0;cursor:pointer;font:inherit;text-decoration:none;margin-right:.8rem}
|
.filters a{
|
||||||
.logout{float:right;color:#f87171;text-decoration:none}
|
color:#93c5fd;
|
||||||
.topbar{float:right;display:flex;gap:.75rem;align-items:center}
|
text-decoration:none;
|
||||||
.topbar form{display:inline}
|
margin-right:1rem;
|
||||||
.topbar button.reset-sm{
|
}
|
||||||
background:#dc2626;
|
.filters a.active{font-weight:bold;color:#fff;}
|
||||||
color:#fff;
|
table{
|
||||||
border:none;
|
width:100%;
|
||||||
border-radius:.4rem;
|
border-collapse:collapse;
|
||||||
padding:.4rem .8rem;
|
background:#1f2937;
|
||||||
font-weight:600;
|
border-radius:.5rem;
|
||||||
cursor:pointer;
|
overflow:hidden;
|
||||||
font-size:.9rem;
|
}
|
||||||
}
|
th,td{padding:.7rem;text-align:left;border-bottom:1px solid #374151;}
|
||||||
.topbar button.reset-sm:hover{background:#b91c1c}
|
th{background:#111827;color:#9ca3af;}
|
||||||
.flash{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-bottom:1rem}
|
tr:hover{background:#2d3748;}
|
||||||
.flash.error{background:#450a0a}
|
.status-badge{
|
||||||
.reset-box{display:none}
|
display:inline-block;
|
||||||
</style>
|
padding:.25rem .6rem;
|
||||||
|
border-radius:9999px;
|
||||||
|
font-size:.8rem;
|
||||||
|
font-weight:600;
|
||||||
|
background:#374151;
|
||||||
|
}
|
||||||
|
.awaiting_payment{background:#f59e0b;color:#000;}
|
||||||
|
.paid,.delivered{background:#10b981;color:#000;}
|
||||||
|
.pending,.prompt_ready{background:#60a5fa;color:#000;}
|
||||||
|
.songs_uploaded{background:#a78bfa;color:#000;}
|
||||||
|
.actions a{
|
||||||
|
color:#93c5fd;
|
||||||
|
text-decoration:none;
|
||||||
|
margin-right:.8rem;
|
||||||
|
}
|
||||||
|
.actions button.delete{
|
||||||
|
background:transparent;
|
||||||
|
color:#f87171;
|
||||||
|
border:none;
|
||||||
|
padding:0;
|
||||||
|
cursor:pointer;
|
||||||
|
font:inherit;
|
||||||
|
text-decoration:none;
|
||||||
|
margin-right:.8rem;
|
||||||
|
}
|
||||||
|
.logout{float:right;color:#f87171;text-decoration:none;}
|
||||||
|
.flash{
|
||||||
|
padding:.8rem;
|
||||||
|
background:#064e3b;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.flash.error{background:#450a0a;}
|
||||||
|
|
||||||
|
/* Topbar with Reset System button and Log out link */
|
||||||
|
.topbar{
|
||||||
|
float:right;
|
||||||
|
display:flex;
|
||||||
|
gap:.75rem;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
.topbar form{display:inline;}
|
||||||
|
.topbar button.reset-sm{
|
||||||
|
background:#dc2626;
|
||||||
|
color:#fff;
|
||||||
|
border:none;
|
||||||
|
border-radius:.4rem;
|
||||||
|
padding:.4rem .8rem;
|
||||||
|
font-weight:600;
|
||||||
|
cursor:pointer;
|
||||||
|
font-size:.9rem;
|
||||||
|
}
|
||||||
|
.topbar button.reset-sm:hover{background:#b91c1c;}
|
||||||
|
|
||||||
|
/* Hidden legacy reset box (kept CSS class for compatibility, not displayed) */
|
||||||
|
.reset-box{display:none;}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="topbar">
|
<!-- Topbar: Reset System button and Log out link -->
|
||||||
<form method="POST" action="{{ url_for('admin_reset_system') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
|
<div class="topbar">
|
||||||
<button type="submit" class="reset-sm">Reset System</button>
|
<form method="POST" action="{{ url_for('admin_reset_system') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
|
||||||
</form>
|
<button type="submit" class="reset-sm">Reset System</button>
|
||||||
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
|
</form>
|
||||||
</div>
|
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
|
||||||
<h1>Theme Song Booth — Admin Dashboard</h1>
|
</div>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
<h1>Theme Song Booth — Admin Dashboard</h1>
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="flash {{ category }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="filters">
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
<a href="{{ url_for('admin_dashboard') }}" class="{% if not current_status %}active{% endif %}">All</a>
|
{% for category, message in messages %}
|
||||||
{% for key,label in statuses.items() %}
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
<a href="{{ url_for('admin_dashboard', status=key) }}" class="{% if current_status == key %}active{% endif %}">{{ label }}</a>
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endwith %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
<!-- Status filter links -->
|
||||||
<thead>
|
<div class="filters">
|
||||||
<tr>
|
<a href="{{ url_for('admin_dashboard') }}" class="{% if not current_status %}active{% endif %}">All</a>
|
||||||
<th>ID</th>
|
{% for key,label in statuses.items() %}
|
||||||
<th>Name</th>
|
<a href="{{ url_for('admin_dashboard', status=key) }}" class="{% if current_status == key %}active{% endif %}">{{ label }}</a>
|
||||||
<th>Email</th>
|
{% endfor %}
|
||||||
<th>Genre</th>
|
</div>
|
||||||
<th>Status</th>
|
|
||||||
<th>Approved</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for r in requests %}
|
|
||||||
<tr>
|
|
||||||
<td>#{{ r.id }}</td>
|
|
||||||
<td>{{ r.name }}</td>
|
|
||||||
<td>{{ r.email }}</td>
|
|
||||||
<td>{{ r.style_genre or '-' }}</td>
|
|
||||||
<td><span class="status-badge {{ r.status }}">{{ statuses[r.status] }}</span></td>
|
|
||||||
<td>{% if r.customer_approved != 'none' %}{{ r.customer_approved.upper() }}{% else %}-{% endif %}</td>
|
|
||||||
<td class="actions"><a href="{{ url_for('admin_request', rid=r.id) }}">Open</a>
|
|
||||||
<form method="POST" action="{{ url_for('admin_delete_request', rid=r.id) }}" style="display:inline" onsubmit="return confirm('ARE YOU SURE you want to delete request #{{ r.id }} for {{ r.name }}? This cannot be undone.')">
|
|
||||||
<button type="submit" class="delete">Delete</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% if not requests %}
|
|
||||||
<tr><td colspan="7">No requests found.</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="reset-box">
|
<!-- Requests table -->
|
||||||
<h2>⚠️ Reset System</h2>
|
<table>
|
||||||
<p>Use this once at the start of an event to clear all orders and uploaded files.</p>
|
<thead>
|
||||||
<form method="POST" action="{{ url_for('admin_reset_system') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
|
<tr>
|
||||||
<button type="submit">Reset System</button>
|
<th>ID</th>
|
||||||
</form>
|
<th>Name</th>
|
||||||
</div>
|
<th>Email</th>
|
||||||
</div>
|
<th>Genre</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Approved</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in requests %}
|
||||||
|
<tr>
|
||||||
|
<td>#{{ r.id }}</td>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td>{{ r.email }}</td>
|
||||||
|
<td>{{ r.style_genre or '-' }}</td>
|
||||||
|
<td><span class="status-badge {{ r.status }}">{{ statuses[r.status] }}</span></td>
|
||||||
|
<td>{% if r.customer_approved != 'none' %}{{ r.customer_approved.upper() }}{% else %}-{% endif %}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="{{ url_for('admin_request', rid=r.id) }}">Open</a>
|
||||||
|
<form method="POST" action="{{ url_for('admin_delete_request', rid=r.id) }}" style="display:inline" onsubmit="return confirm('ARE YOU SURE you want to delete request #{{ r.id }} for {{ r.name }}? This cannot be undone.')">
|
||||||
|
<button type="submit" class="delete">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not requests %}
|
||||||
|
<tr><td colspan="7">No requests found.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,70 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Admin Login</title>
|
<title>Admin Login</title>
|
||||||
<style>
|
<style>
|
||||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;display:flex;justify-content:center;align-items:center;min-height:100vh}
|
/*
|
||||||
form{background:#1f2937;padding:2rem;border-radius:1rem;width:100%;max-width:360px}
|
Minimal login page for the operator dashboard.
|
||||||
h1{margin-top:0;color:#60a5fa}
|
Centered card with dark theme matching the rest of the app.
|
||||||
label{display:block;margin-top:1rem;font-weight:600}
|
*/
|
||||||
input{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box}
|
body{
|
||||||
button{margin-top:1.5rem;width:100%;padding:.8rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer}
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
.flash{margin-top:1rem;color:#f87171}
|
background:#111827;
|
||||||
</style>
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
display:flex;
|
||||||
|
justify-content:center;
|
||||||
|
align-items:center;
|
||||||
|
min-height:100vh;
|
||||||
|
}
|
||||||
|
form{
|
||||||
|
background:#1f2937;
|
||||||
|
padding:2rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
width:100%;
|
||||||
|
max-width:360px;
|
||||||
|
}
|
||||||
|
h1{margin-top:0;color:#60a5fa;}
|
||||||
|
label{display:block;margin-top:1rem;font-weight:600;}
|
||||||
|
input{
|
||||||
|
width:100%;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
button{
|
||||||
|
margin-top:1.5rem;
|
||||||
|
width:100%;
|
||||||
|
padding:.8rem;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background:#3b82f6;
|
||||||
|
color:#fff;
|
||||||
|
font-weight:700;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.flash{margin-top:1rem;color:#f87171;}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<h1>Booth Admin</h1>
|
<h1>Booth Admin</h1>
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" name="password" required autofocus>
|
<input type="password" id="password" name="password" required autofocus>
|
||||||
{% with messages = get_flashed_messages() %}
|
|
||||||
{% if messages %}
|
{% with messages = get_flashed_messages() %}
|
||||||
<div class="flash">{{ messages[0] }}</div>
|
{% if messages %}
|
||||||
{% endif %}
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
{% endwith %}
|
{% endif %}
|
||||||
<button type="submit">Log In</button>
|
{% endwith %}
|
||||||
</form>
|
|
||||||
|
<button type="submit">Log In</button>
|
||||||
|
</form>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,212 +1,283 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Request #{{ req.id }} — Admin</title>
|
<title>Request #{{ req.id }} — Admin</title>
|
||||||
<style>
|
<style>
|
||||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
|
/*
|
||||||
.container{max-width:900px;margin:0 auto}
|
Single-request admin detail page.
|
||||||
h1,h2{color:#60a5fa}
|
Four sections:
|
||||||
a{color:#93c5fd}
|
1. Generate Suno prompt (copy to Hermes, paste response, extract/copy)
|
||||||
.section{background:#1f2937;padding:1rem;border-radius:.5rem;margin-bottom:1rem}
|
2. Upload Songs (with status badges)
|
||||||
label{display:block;margin-top:.8rem;font-weight:600}
|
3. Notify Customer (preview email status + send button)
|
||||||
input,textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box;font:inherit}
|
4. Payment & Delivery (approval status + deliver button)
|
||||||
textarea{min-height:120px}
|
*/
|
||||||
button{padding:.7rem 1rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer;margin-top:.5rem}
|
body{
|
||||||
button.secondary{background:#4b5563}
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
button.danger{background:#dc2626}
|
background:#111827;
|
||||||
button.success{background:#10b981}
|
color:#f3f4f6;
|
||||||
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem}
|
margin:0;
|
||||||
.flash{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-bottom:1rem}
|
padding:1rem;
|
||||||
.flash.error{background:#450a0a}
|
line-height:1.5;
|
||||||
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
|
}
|
||||||
.info-grid div{background:#111827;padding:.6rem;border-radius:.5rem}
|
.container{max-width:900px;margin:0 auto;}
|
||||||
.approved-box{font-size:1.2rem;font-weight:bold;color:#fbbf24}
|
h1,h2{color:#60a5fa;}
|
||||||
.status-badge{display:inline-block;padding:.25rem .6rem;border-radius:9999px;font-size:.8rem;font-weight:600;background:#374151;color:#f3f4f6;margin-right:.3rem}
|
a{color:#93c5fd;}
|
||||||
.status-badge.ok{background:#10b981;color:#000}
|
.section{
|
||||||
.status-badge.missing{background:#ef4444;color:#000}
|
background:#1f2937;
|
||||||
.status-badge.sent{background:#60a5fa;color:#000}
|
padding:1rem;
|
||||||
</style>
|
border-radius:.5rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
label{display:block;margin-top:.8rem;font-weight:600;}
|
||||||
|
input,textarea{
|
||||||
|
width:100%;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
box-sizing:border-box;
|
||||||
|
font:inherit;
|
||||||
|
}
|
||||||
|
textarea{min-height:120px;}
|
||||||
|
button{
|
||||||
|
padding:.7rem 1rem;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background:#3b82f6;
|
||||||
|
color:#fff;
|
||||||
|
font-weight:700;
|
||||||
|
cursor:pointer;
|
||||||
|
margin-top:.5rem;
|
||||||
|
}
|
||||||
|
button.secondary{background:#4b5563;}
|
||||||
|
button.danger{background:#dc2626;}
|
||||||
|
button.success{background:#10b981;}
|
||||||
|
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem;}
|
||||||
|
.flash{
|
||||||
|
padding:.8rem;
|
||||||
|
background:#064e3b;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.flash.error{background:#450a0a;}
|
||||||
|
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;}
|
||||||
|
.info-grid div{
|
||||||
|
background:#111827;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
}
|
||||||
|
.approved-box{font-size:1.2rem;font-weight:bold;color:#fbbf24;}
|
||||||
|
.status-badge{
|
||||||
|
display:inline-block;
|
||||||
|
padding:.25rem .6rem;
|
||||||
|
border-radius:9999px;
|
||||||
|
font-size:.8rem;
|
||||||
|
font-weight:600;
|
||||||
|
background:#374151;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin-right:.3rem;
|
||||||
|
}
|
||||||
|
.status-badge.ok{background:#10b981;color:#000;}
|
||||||
|
.status-badge.missing{background:#ef4444;color:#000;}
|
||||||
|
.status-badge.sent{background:#60a5fa;color:#000;}
|
||||||
|
.copy-hint{font-size:.85rem;color:#9ca3af;margin-top:.3rem;}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p><a href="{{ url_for('admin_dashboard') }}">← Dashboard</a></p>
|
<p><a href="{{ url_for('admin_dashboard') }}">← Dashboard</a></p>
|
||||||
<h1>Request #{{ req.id }} — {{ req.name }}</h1>
|
<h1>Request #{{ req.id }} — {{ req.name }}</h1>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
<div class="flash {{ category }}">{{ message }}</div>
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<div class="section">
|
<!-- Section 1: Customer info summary -->
|
||||||
<h2>Customer Info</h2>
|
<div class="section">
|
||||||
<div class="info-grid">
|
<h2>Customer Info</h2>
|
||||||
<div><strong>Email:</strong> {{ req.email }}</div>
|
<div class="info-grid">
|
||||||
<div><strong>Status:</strong> {{ statuses[req.status] }}</div>
|
<div><strong>Email:</strong> {{ req.email }}</div>
|
||||||
</div>
|
<div><strong>Status:</strong> {{ statuses[req.status] }}</div>
|
||||||
<p><strong>Hobbies:</strong><br>{{ req.hobbies or '-' }}</p>
|
</div>
|
||||||
<p><strong>Notable facts:</strong><br>{{ req.notable_facts or '-' }}</p>
|
<p><strong>Hobbies:</strong><br>{{ req.hobbies or '-' }}</p>
|
||||||
<p><strong>Style / genre:</strong><br>{{ req.style_genre or '-' }}</p>
|
<p><strong>Notable facts:</strong><br>{{ req.notable_facts or '-' }}</p>
|
||||||
<p><strong>Extra requests:</strong><br>{{ req.extra_requests or '-' }}</p>
|
<p><strong>Style / genre:</strong><br>{{ req.style_genre or '-' }}</p>
|
||||||
</div>
|
<p><strong>Extra requests:</strong><br>{{ req.extra_requests or '-' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<!-- Section 2: Generate Suno prompt -->
|
||||||
<h2>1. Generate Suno Prompt</h2>
|
<div class="section">
|
||||||
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
|
<h2>1. Generate Suno Prompt</h2>
|
||||||
<p class="copy-hint">Paste Hermes response into the box below. It will auto-extract Title, Style, and Lyrics.</p>
|
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
|
||||||
|
<p class="copy-hint">Paste Hermes response into the box below. It will auto-extract Title, Style, and Lyrics.</p>
|
||||||
|
|
||||||
<label for="hermes_response">Paste Hermes response here</label>
|
<label for="hermes_response">Paste Hermes response here</label>
|
||||||
<textarea id="hermes_response" rows="14" placeholder="Title: ...\nStyle: ...\nLyrics: ..."></textarea>
|
<textarea id="hermes_response" rows="14" placeholder="Title: ...\nStyle: ...\nLyrics: ..."></textarea>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="secondary" onclick="extractHermesResponse()">Extract Title / Style / Lyrics</button>
|
<button type="button" class="secondary" onclick="extractHermesResponse()">Extract Title / Style / Lyrics</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="action" value="save_prompt">
|
<input type="hidden" name="action" value="save_prompt">
|
||||||
|
|
||||||
<label for="suno_title">Suggested Title</label>
|
<label for="suno_title">Suggested Title</label>
|
||||||
<input type="text" id="suno_title" name="suno_title" value="{{ req.suno_title or '' }}" readonly>
|
<input type="text" id="suno_title" name="suno_title" value="{{ req.suno_title or '' }}" readonly>
|
||||||
|
|
||||||
<label for="suno_style">Suno Style</label>
|
<label for="suno_style">Suno Style</label>
|
||||||
<textarea id="suno_style" name="suno_style">{{ req.suno_style or '' }}</textarea>
|
<textarea id="suno_style" name="suno_style">{{ req.suno_style or '' }}</textarea>
|
||||||
|
|
||||||
<label for="suno_lyrics">Suno Lyrics (with metatags)</label>
|
<label for="suno_lyrics">Suno Lyrics (with metatags)</label>
|
||||||
<textarea id="suno_lyrics" name="suno_lyrics" rows="12">{{ req.suno_lyrics or '' }}</textarea>
|
<textarea id="suno_lyrics" name="suno_lyrics" rows="12">{{ req.suno_lyrics or '' }}</textarea>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="secondary" onclick="copyToClipboard('suno_title', 'Title')">Copy Title</button>
|
<button type="button" class="secondary" onclick="copyToClipboard('suno_title', 'Title')">Copy Title</button>
|
||||||
<button type="button" class="secondary" onclick="copyToClipboard('suno_style', 'Style')">Copy Style</button>
|
<button type="button" class="secondary" onclick="copyToClipboard('suno_style', 'Style')">Copy Style</button>
|
||||||
<button type="button" class="secondary" onclick="copyToClipboard('suno_lyrics', 'Lyrics')">Copy Lyrics</button>
|
<button type="button" class="secondary" onclick="copyToClipboard('suno_lyrics', 'Lyrics')">Copy Lyrics</button>
|
||||||
<button type="submit">Save Prompt</button>
|
<button type="submit">Save Prompt</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<!-- Section 3: Upload songs -->
|
||||||
<h2>2. Upload Songs</h2>
|
<div class="section">
|
||||||
<p>
|
<h2>2. Upload Songs</h2>
|
||||||
Version A:
|
<p>
|
||||||
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
Version A:
|
||||||
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_a_path) }}
|
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
||||||
{% else %}
|
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_a_path) }}
|
||||||
<span class="status-badge missing">❌ Not uploaded</span>
|
{% else %}
|
||||||
{% endif %}
|
<span class="status-badge missing">❌ Not uploaded</span>
|
||||||
</p>
|
{% endif %}
|
||||||
<p>
|
</p>
|
||||||
Version B:
|
<p>
|
||||||
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
Version B:
|
||||||
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_b_path) }}
|
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
||||||
{% else %}
|
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_b_path) }}
|
||||||
<span class="status-badge missing">❌ Not uploaded</span>
|
{% else %}
|
||||||
{% endif %}
|
<span class="status-badge missing">❌ Not uploaded</span>
|
||||||
</p>
|
{% endif %}
|
||||||
<form method="POST" enctype="multipart/form-data">
|
</p>
|
||||||
<input type="hidden" name="action" value="upload_songs">
|
|
||||||
<label for="song_a">Version A MP3</label>
|
|
||||||
<input type="file" id="song_a" name="song_a" accept="audio/mpeg,.mp3">
|
|
||||||
<label for="song_b">Version B MP3</label>
|
|
||||||
<input type="file" id="song_b" name="song_b" accept="audio/mpeg,.mp3">
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit">Upload Songs</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
<h2>3. Notify Customer</h2>
|
<input type="hidden" name="action" value="upload_songs">
|
||||||
<p>
|
<label for="song_a">Version A MP3</label>
|
||||||
Preview email:
|
<input type="file" id="song_a" name="song_a" accept="audio/mpeg,.mp3">
|
||||||
{% if req.preview_sent_at %}
|
<label for="song_b">Version B MP3</label>
|
||||||
<span class="status-badge sent">✅ Sent</span> {{ req.preview_sent_at }}
|
<input type="file" id="song_b" name="song_b" accept="audio/mpeg,.mp3">
|
||||||
{% else %}
|
<div class="actions">
|
||||||
<span class="status-badge missing">❌ Not sent yet</span>
|
<button type="submit">Upload Songs</button>
|
||||||
{% endif %}
|
</div>
|
||||||
</p>
|
</form>
|
||||||
<p>Both songs must be uploaded first.</p>
|
</div>
|
||||||
<form method="POST">
|
|
||||||
<input type="hidden" name="action" value="notify_customer">
|
|
||||||
<button type="submit" {% if not (req.song_a_path and req.song_b_path) %}disabled{% endif %}>Send Preview Link</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
<!-- Section 4: Notify customer -->
|
||||||
<h2>4. Payment & Delivery</h2>
|
<div class="section">
|
||||||
<p>Customer approved:
|
<h2>3. Notify Customer</h2>
|
||||||
{% if req.customer_approved == 'none' %}
|
<p>
|
||||||
<span class="approved-box">Nothing yet</span>
|
Preview email:
|
||||||
{% else %}
|
{% if req.preview_sent_at %}
|
||||||
<span class="approved-box">{{ req.customer_approved.upper() }}</span>
|
<span class="status-badge sent">✅ Sent</span> {{ req.preview_sent_at }}
|
||||||
{% endif %}
|
{% else %}
|
||||||
</p>
|
<span class="status-badge missing">❌ Not sent yet</span>
|
||||||
<p>
|
{% endif %}
|
||||||
Delivery email:
|
</p>
|
||||||
{% if req.delivery_sent_at %}
|
<p>Both songs must be uploaded first.</p>
|
||||||
<span class="status-badge sent">✅ Sent</span> {{ req.delivery_sent_at }}
|
<form method="POST">
|
||||||
{% else %}
|
<input type="hidden" name="action" value="notify_customer">
|
||||||
<span class="status-badge missing">❌ Not sent yet</span>
|
<button type="submit" {% if not (req.song_a_path and req.song_b_path) %}disabled{% endif %}>Send Preview Link</button>
|
||||||
{% endif %}
|
</form>
|
||||||
</p>
|
</div>
|
||||||
<form method="POST">
|
|
||||||
<input type="hidden" name="action" value="mark_paid_deliver">
|
|
||||||
<label for="square_payment_ref">Square Payment Reference</label>
|
|
||||||
<input type="text" id="square_payment_ref" name="square_payment_ref" placeholder="e.g. sq0idp-... or receipt number">
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid & Deliver</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<!-- Section 5: Payment & delivery -->
|
||||||
function copyPromptForHermes() {
|
<div class="section">
|
||||||
const data = {
|
<h2>4. Payment & Delivery</h2>
|
||||||
name: {{ req.name | tojson }},
|
<p>
|
||||||
hobbies: {{ req.hobbies | tojson }},
|
Customer approved:
|
||||||
notable_facts: {{ req.notable_facts | tojson }},
|
{% if req.customer_approved == 'none' %}
|
||||||
style_genre: {{ req.style_genre | tojson }},
|
<span class="approved-box">Nothing yet</span>
|
||||||
extra_requests: {{ req.extra_requests | tojson }}
|
{% else %}
|
||||||
};
|
<span class="approved-box">{{ req.customer_approved.upper() }}</span>
|
||||||
const text = `Please write a Suno Custom Mode prompt for this customer. Suggest a song title too.\n\nCustomer data:\nName: ${data.name}\nHobbies: ${data.hobbies || '-'}\nNotable facts: ${data.notable_facts || '-'}\nStyle / genre: ${data.style_genre || '-'}\nExtra requests: ${data.extra_requests || '-'}\n\nReply with exactly this format:\n\nTitle: [suggested title]\n\nStyle:\n[the style description here]\n\nLyrics:\n[the lyrics here, with metatags]`;
|
{% endif %}
|
||||||
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste Hermes response into the box above and click Extract."));
|
</p>
|
||||||
}
|
<p>
|
||||||
|
Delivery email:
|
||||||
|
{% if req.delivery_sent_at %}
|
||||||
|
<span class="status-badge sent">✅ Sent</span> {{ req.delivery_sent_at }}
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge missing">❌ Not sent yet</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="mark_paid_deliver">
|
||||||
|
<label for="square_payment_ref">Square Payment Reference</label>
|
||||||
|
<input type="text" id="square_payment_ref" name="square_payment_ref" placeholder="e.g. sq0idp-... or receipt number">
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid & Deliver</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
function extractHermesResponse() {
|
<script>
|
||||||
const raw = document.getElementById('hermes_response').value;
|
/*
|
||||||
if (!raw.trim()) {
|
Client-side helpers for the admin detail page.
|
||||||
alert("Paste Hermes response first.");
|
- copyPromptForHermes(): builds a plain-text prompt and copies it.
|
||||||
return;
|
- extractHermesResponse(): parses Title/Style/Lyrics from pasted text.
|
||||||
}
|
- copyToClipboard(): copies a field to the clipboard for pasting into Suno.
|
||||||
|
*/
|
||||||
|
|
||||||
const titleMatch = raw.match(/^[\s]*Title:\s*([\s\S]*?)(?=\n\s*Style:|\n\s*Lyrics:|$)/im);
|
function copyPromptForHermes() {
|
||||||
const styleMatch = raw.match(/^[\s]*Style:\s*([\s\S]*?)(?=\n\s*Lyrics:|$)/im);
|
const data = {
|
||||||
const lyricsMatch = raw.match(/^[\s]*Lyrics:\s*([\s\S]*?)$/im);
|
name: {{ req.name | tojson }},
|
||||||
|
hobbies: {{ req.hobbies | tojson }},
|
||||||
|
notable_facts: {{ req.notable_facts | tojson }},
|
||||||
|
style_genre: {{ req.style_genre | tojson }},
|
||||||
|
extra_requests: {{ req.extra_requests | tojson }}
|
||||||
|
};
|
||||||
|
const text = `Please write a Suno Custom Mode prompt for this customer. Suggest a song title too.\n\nCustomer data:\nName: ${data.name}\nHobbies: ${data.hobbies || '-'}\nNotable facts: ${data.notable_facts || '-'}\nStyle / genre: ${data.style_genre || '-'}\nExtra requests: ${data.extra_requests || '-'}\n\nReply with exactly this format:\n\nTitle: [suggested title]\n\nStyle:\n[the style description here]\n\nLyrics:\n[the lyrics here, with metatags]`;
|
||||||
|
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste Hermes response into the box above and click Extract."));
|
||||||
|
}
|
||||||
|
|
||||||
if (titleMatch) {
|
function extractHermesResponse() {
|
||||||
document.getElementById('suno_title').value = titleMatch[1].trim();
|
const raw = document.getElementById('hermes_response').value;
|
||||||
}
|
if (!raw.trim()) {
|
||||||
if (styleMatch) {
|
alert("Paste Hermes response first.");
|
||||||
document.getElementById('suno_style').value = styleMatch[1].trim();
|
return;
|
||||||
}
|
}
|
||||||
if (lyricsMatch) {
|
|
||||||
document.getElementById('suno_lyrics').value = lyricsMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!styleMatch && !lyricsMatch) {
|
const titleMatch = raw.match(/^[\s]*Title:\s*([\s\S]*?)(?=\n\s*Style:|\n\s*Lyrics:|$)/im);
|
||||||
alert("Could not find Style or Lyrics sections. Make sure the response uses the exact format.");
|
const styleMatch = raw.match(/^[\s]*Style:\s*([\s\S]*?)(?=\n\s*Lyrics:|$)/im);
|
||||||
} else {
|
const lyricsMatch = raw.match(/^[\s]*Lyrics:\s*([\s\S]*?)$/im);
|
||||||
alert("Title, Style, and Lyrics extracted. Use Copy buttons to paste into Suno.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard(elementId, label) {
|
if (titleMatch) {
|
||||||
const el = document.getElementById(elementId);
|
document.getElementById('suno_title').value = titleMatch[1].trim();
|
||||||
el.select();
|
}
|
||||||
el.setSelectionRange(0, 99999);
|
if (styleMatch) {
|
||||||
navigator.clipboard.writeText(el.value).then(() => alert(label + " copied!"));
|
document.getElementById('suno_style').value = styleMatch[1].trim();
|
||||||
}
|
}
|
||||||
</script>
|
if (lyricsMatch) {
|
||||||
</div>
|
document.getElementById('suno_lyrics').value = lyricsMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!styleMatch && !lyricsMatch) {
|
||||||
|
alert("Could not find Style or Lyrics sections. Make sure the response uses the exact format.");
|
||||||
|
} else {
|
||||||
|
alert("Title, Style, and Lyrics extracted. Use Copy buttons to paste into Suno.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(elementId, label) {
|
||||||
|
const el = document.getElementById(elementId);
|
||||||
|
el.select();
|
||||||
|
el.setSelectionRange(0, 99999);
|
||||||
|
navigator.clipboard.writeText(el.value).then(() => alert(label + " copied!"));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,125 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Your Theme Song</title>
|
<title>Your Theme Song</title>
|
||||||
<style>
|
<style>
|
||||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
|
/*
|
||||||
.container{max-width:640px;margin:0 auto;background:#1f2937;padding:1.5rem;border-radius:1rem}
|
Private customer player page.
|
||||||
h1{color:#60a5fa}
|
Shows two audio players for Version A and Version B,
|
||||||
.player{background:#111827;padding:1rem;border-radius:.5rem;margin:1rem 0}
|
plus approval buttons and a revision note form.
|
||||||
audio{width:100%;margin-top:.5rem}
|
*/
|
||||||
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1rem}
|
body{
|
||||||
button{flex:1;min-width:120px;padding:.8rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer}
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
button.selected{background:#10b981}
|
background:#111827;
|
||||||
button.both{background:#8b5cf6}
|
color:#f3f4f6;
|
||||||
button.revision{background:#f59e0b;color:#000}
|
margin:0;
|
||||||
form{margin-top:1rem}
|
padding:1rem;
|
||||||
textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box;min-height:80px}
|
line-height:1.5;
|
||||||
.status{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-top:1rem}
|
}
|
||||||
.status.waiting{background:#3f3f46}
|
.container{
|
||||||
</style>
|
max-width:640px;
|
||||||
|
margin:0 auto;
|
||||||
|
background:#1f2937;
|
||||||
|
padding:1.5rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
}
|
||||||
|
h1{color:#60a5fa;}
|
||||||
|
.player{
|
||||||
|
background:#111827;
|
||||||
|
padding:1rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin:1rem 0;
|
||||||
|
}
|
||||||
|
audio{width:100%;margin-top:.5rem;}
|
||||||
|
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1rem;}
|
||||||
|
button{
|
||||||
|
flex:1;
|
||||||
|
min-width:120px;
|
||||||
|
padding:.8rem;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background:#3b82f6;
|
||||||
|
color:#fff;
|
||||||
|
font-weight:700;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
button.selected{background:#10b981;}
|
||||||
|
button.both{background:#8b5cf6;}
|
||||||
|
button.revision{background:#f59e0b;color:#000;}
|
||||||
|
form{margin-top:1rem;}
|
||||||
|
textarea{
|
||||||
|
width:100%;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
box-sizing:border-box;
|
||||||
|
min-height:80px;
|
||||||
|
}
|
||||||
|
.status{
|
||||||
|
padding:.8rem;
|
||||||
|
background:#064e3b;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin-top:1rem;
|
||||||
|
}
|
||||||
|
.status.waiting{background:#3f3f46;}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>🎧 Your Custom Theme Song</h1>
|
<h1>🎧 Your Custom Theme Song</h1>
|
||||||
<p>Hi {{ req.name }}! Listen to both versions and pick the one you want.</p>
|
<p>Hi {{ req.name }}! Listen to both versions and pick the one you want.</p>
|
||||||
|
|
||||||
<div class="player">
|
<!-- Version A player -->
|
||||||
<h3>Version A</h3>
|
<div class="player">
|
||||||
<audio controls src="{{ url_for('audio', token=req.player_token, version='a') }}"></audio>
|
<h3>Version A</h3>
|
||||||
</div>
|
<audio controls src="{{ url_for('audio', token=req.player_token, version='a') }}"></audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="player">
|
<!-- Version B player -->
|
||||||
<h3>Version B</h3>
|
<div class="player">
|
||||||
<audio controls src="{{ url_for('audio', token=req.player_token, version='b') }}"></audio>
|
<h3>Version B</h3>
|
||||||
</div>
|
<audio controls src="{{ url_for('audio', token=req.player_token, version='b') }}"></audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if req.status in ['songs_uploaded','awaiting_payment','paid','delivered'] %}
|
{% if req.status in ['songs_uploaded','awaiting_payment','paid','delivered'] %}
|
||||||
<form method="POST" action="{{ url_for('approve', token=req.player_token) }}">
|
<!-- Approval form: customer picks A, B, or both -->
|
||||||
<input type="hidden" name="choice" id="choice">
|
<form method="POST" action="{{ url_for('approve', token=req.player_token) }}">
|
||||||
<div class="actions">
|
<input type="hidden" name="choice" id="choice">
|
||||||
<button type="submit" class="{% if req.customer_approved == 'a' %}selected{% endif %}" onclick="document.getElementById('choice').value='a'">I want Version A</button>
|
<div class="actions">
|
||||||
<button type="submit" class="{% if req.customer_approved == 'b' %}selected{% endif %}" onclick="document.getElementById('choice').value='b'">I want Version B</button>
|
<button type="submit" class="{% if req.customer_approved == 'a' %}selected{% endif %}" onclick="document.getElementById('choice').value='a'">I want Version A</button>
|
||||||
<button type="submit" class="both {% if req.customer_approved == 'both' %}selected{% endif %}" onclick="document.getElementById('choice').value='both'">I want both</button>
|
<button type="submit" class="{% if req.customer_approved == 'b' %}selected{% endif %}" onclick="document.getElementById('choice').value='b'">I want Version B</button>
|
||||||
</div>
|
<button type="submit" class="both {% if req.customer_approved == 'both' %}selected{% endif %}" onclick="document.getElementById('choice').value='both'">I want both</button>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('revise', token=req.player_token) }}">
|
<!-- Revision form: customer asks for changes -->
|
||||||
<label for="revision_note">Or ask for changes:</label>
|
<form method="POST" action="{{ url_for('revise', token=req.player_token) }}">
|
||||||
<textarea id="revision_note" name="revision_note" placeholder="e.g. make the chorus louder, swap a lyric..."></textarea>
|
<label for="revision_note">Or ask for changes:</label>
|
||||||
<div class="actions">
|
<textarea id="revision_note" name="revision_note" placeholder="e.g. make the chorus louder, swap a lyric..."></textarea>
|
||||||
<button type="submit" class="revision">Request Changes</button>
|
<div class="actions">
|
||||||
</div>
|
<button type="submit" class="revision">Request Changes</button>
|
||||||
</form>
|
</div>
|
||||||
{% endif %}
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if req.status == 'awaiting_payment' %}
|
{% if req.status == 'awaiting_payment' %}
|
||||||
<div class="status waiting"><strong>Thanks for choosing {{ req.customer_approved.upper() }}!</strong> Please head to the booth to finalize payment and collect your files.</div>
|
<!-- Shown after customer approves a version -->
|
||||||
{% endif %}
|
<div class="status waiting">
|
||||||
|
<strong>Thanks for choosing {{ req.customer_approved.upper() }}!</strong> Please head to the booth to finalize payment and collect your files.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if req.status == 'delivered' %}
|
{% if req.status == 'delivered' %}
|
||||||
<div class="status"><strong>Delivered! ✅</strong> Check your email for the MP3 attachment(s).</div>
|
<!-- Shown after operator marks paid and delivers -->
|
||||||
{% endif %}
|
<div class="status">
|
||||||
|
<strong>Delivered! ✅</strong> Check your email for the MP3 attachment(s).
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,136 +1,134 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Request Your Theme Song</title>
|
<title>Request Your Theme Song</title>
|
||||||
<style>
|
<style>
|
||||||
*{
|
/*
|
||||||
box-sizing:border-box;
|
Customer-facing request page.
|
||||||
}
|
Designed to look fun and inviting on a convention booth tablet or phone.
|
||||||
body{
|
Uses a gradient background, banner image, and a styled card form.
|
||||||
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
*/
|
||||||
background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%);
|
*{box-sizing:border-box;}
|
||||||
color:#f3f4f6;
|
body{
|
||||||
margin:0;
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
padding:1rem;
|
background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%);
|
||||||
line-height:1.5;
|
color:#f3f4f6;
|
||||||
min-height:100vh;
|
margin:0;
|
||||||
}
|
padding:1rem;
|
||||||
.container{
|
line-height:1.5;
|
||||||
max-width:680px;
|
min-height:100vh;
|
||||||
margin:0 auto;
|
}
|
||||||
}
|
.container{max-width:680px;margin:0 auto;}
|
||||||
.banner{
|
.banner{
|
||||||
width:100%;
|
width:100%;
|
||||||
border-radius:1rem;
|
border-radius:1rem;
|
||||||
box-shadow:0 10px 40px rgba(0,0,0,.4);
|
box-shadow:0 10px 40px rgba(0,0,0,.4);
|
||||||
margin-bottom:1.5rem;
|
margin-bottom:1.5rem;
|
||||||
}
|
}
|
||||||
.card{
|
.card{
|
||||||
background:rgba(31,41,55,.9);
|
background:rgba(31,41,55,.9);
|
||||||
padding:1.5rem;
|
padding:1.5rem;
|
||||||
border-radius:1rem;
|
border-radius:1rem;
|
||||||
box-shadow:0 10px 25px rgba(0,0,0,.3);
|
box-shadow:0 10px 25px rgba(0,0,0,.3);
|
||||||
border:1px solid rgba(96,165,250,.2);
|
border:1px solid rgba(96,165,250,.2);
|
||||||
}
|
}
|
||||||
h1{
|
h1{
|
||||||
margin-top:0;
|
margin-top:0;
|
||||||
color:#60a5fa;
|
color:#60a5fa;
|
||||||
text-align:center;
|
text-align:center;
|
||||||
font-size:1.7rem;
|
font-size:1.7rem;
|
||||||
}
|
}
|
||||||
.subtitle{
|
.subtitle{
|
||||||
text-align:center;
|
text-align:center;
|
||||||
color:#9ca3af;
|
color:#9ca3af;
|
||||||
margin-bottom:1.5rem;
|
margin-bottom:1.5rem;
|
||||||
}
|
}
|
||||||
label{
|
label{
|
||||||
display:block;
|
display:block;
|
||||||
margin-top:1rem;
|
margin-top:1rem;
|
||||||
font-weight:600;
|
font-weight:600;
|
||||||
color:#93c5fd;
|
color:#93c5fd;
|
||||||
}
|
}
|
||||||
input,textarea{
|
input,textarea{
|
||||||
width:100%;
|
width:100%;
|
||||||
padding:.7rem;
|
padding:.7rem;
|
||||||
border-radius:.5rem;
|
border-radius:.5rem;
|
||||||
border:1px solid #374151;
|
border:1px solid #374151;
|
||||||
background:#111827;
|
background:#111827;
|
||||||
color:#f3f4f6;
|
color:#f3f4f6;
|
||||||
font:inherit;
|
font:inherit;
|
||||||
margin-top:.25rem;
|
margin-top:.25rem;
|
||||||
}
|
}
|
||||||
input:focus,textarea:focus{
|
input:focus,textarea:focus{
|
||||||
outline:none;
|
outline:none;
|
||||||
border-color:#60a5fa;
|
border-color:#60a5fa;
|
||||||
box-shadow:0 0 0 3px rgba(96,165,250,.2);
|
box-shadow:0 0 0 3px rgba(96,165,250,.2);
|
||||||
}
|
}
|
||||||
textarea{
|
textarea{min-height:90px;resize:vertical;}
|
||||||
min-height:90px;
|
button{
|
||||||
resize:vertical;
|
margin-top:1.5rem;
|
||||||
}
|
width:100%;
|
||||||
button{
|
padding:1rem;
|
||||||
margin-top:1.5rem;
|
border:none;
|
||||||
width:100%;
|
border-radius:.5rem;
|
||||||
padding:1rem;
|
background:linear-gradient(90deg,#3b82f6,#8b5cf6);
|
||||||
border:none;
|
color:#fff;
|
||||||
border-radius:.5rem;
|
font-weight:700;
|
||||||
background:linear-gradient(90deg,#3b82f6,#8b5cf6);
|
font-size:1.1rem;
|
||||||
color:#fff;
|
cursor:pointer;
|
||||||
font-weight:700;
|
box-shadow:0 4px 15px rgba(59,130,246,.4);
|
||||||
font-size:1.1rem;
|
transition:transform .1s,box-shadow .1s;
|
||||||
cursor:pointer;
|
}
|
||||||
box-shadow:0 4px 15px rgba(59,130,246,.4);
|
button:hover{
|
||||||
transition:transform .1s,box-shadow .1s;
|
transform:translateY(-2px);
|
||||||
}
|
box-shadow:0 6px 20px rgba(139,92,246,.5);
|
||||||
button:hover{
|
}
|
||||||
transform:translateY(-2px);
|
.note{
|
||||||
box-shadow:0 6px 20px rgba(139,92,246,.5);
|
margin-top:1rem;
|
||||||
}
|
font-size:.9rem;
|
||||||
.note{
|
color:#9ca3af;
|
||||||
margin-top:1rem;
|
text-align:center;
|
||||||
font-size:.9rem;
|
}
|
||||||
color:#9ca3af;
|
.sparkle{
|
||||||
text-align:center;
|
font-size:1.3rem;
|
||||||
}
|
vertical-align:middle;
|
||||||
.sparkle{
|
}
|
||||||
font-size:1.3rem;
|
</style>
|
||||||
vertical-align:middle;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<img src="{{ url_for('static', filename='Trollgorithm_booth.jpg') }}" alt="Trollgorithm Theme Song Booth" class="banner">
|
<!-- Banner graphic for the booth. Served from /static. -->
|
||||||
|
<img src="{{ url_for('static', filename='Trollgorithm_booth.jpg') }}" alt="Trollgorithm Theme Song Booth" class="banner">
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1><span class="sparkle">🎵</span> Get Your Custom Theme Song <span class="sparkle">🎶</span></h1>
|
<h1><span class="sparkle">🎵</span> Get Your Custom Theme Song <span class="sparkle">🎶</span></h1>
|
||||||
<p class="subtitle">Tell us about yourself and we'll craft a one-of-a-kind song just for you.</p>
|
<p class="subtitle">Tell us about yourself and we'll craft a one-of-a-kind song just for you.</p>
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<label for="name">Name / persona you want in the song</label>
|
<label for="name">Name / persona you want in the song</label>
|
||||||
<input type="text" id="name" name="name" required>
|
<input type="text" id="name" name="name" required>
|
||||||
|
|
||||||
<label for="email">Email address</label>
|
<label for="email">Email address</label>
|
||||||
<input type="email" id="email" name="email" required>
|
<input type="email" id="email" name="email" required>
|
||||||
|
|
||||||
<label for="hobbies">Hobbies & interests</label>
|
<label for="hobbies">Hobbies & interests</label>
|
||||||
<textarea id="hobbies" name="hobbies" placeholder="e.g. rock climbing, retro gaming, sourdough baking"></textarea>
|
<textarea id="hobbies" name="hobbies" placeholder="e.g. rock climbing, retro gaming, sourdough baking"></textarea>
|
||||||
|
|
||||||
<label for="notable_facts">Notable things about you</label>
|
<label for="notable_facts">Notable things about you</label>
|
||||||
<textarea id="notable_facts" name="notable_facts" placeholder="Anything fun, weird, or heroic we should mention"></textarea>
|
<textarea id="notable_facts" name="notable_facts" placeholder="Anything fun, weird, or heroic we should mention"></textarea>
|
||||||
|
|
||||||
<label for="style_genre">Style / genre / mood</label>
|
<label for="style_genre">Style / genre / mood</label>
|
||||||
<input type="text" id="style_genre" name="style_genre" placeholder="e.g. 80s power ballad, cinematic orchestral, lo-fi synthwave">
|
<input type="text" id="style_genre" name="style_genre" placeholder="e.g. 80s power ballad, cinematic orchestral, lo-fi synthwave">
|
||||||
|
|
||||||
<label for="extra_requests">Anything else you want in the song?</label>
|
<label for="extra_requests">Anything else you want in the song?</label>
|
||||||
<textarea id="extra_requests" name="extra_requests" placeholder="Specific lyrics, vibe, clean/explicit, vocal gender..."></textarea>
|
<textarea id="extra_requests" name="extra_requests" placeholder="Specific lyrics, vibe, clean/explicit, vocal gender..."></textarea>
|
||||||
|
|
||||||
<button type="submit">Submit Request</button>
|
<button type="submit">Submit Request</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="note">Your info is only used to create and deliver your song.</p>
|
<p class="note">Your info is only used to create and deliver your song.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,44 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Request Received</title>
|
<title>Request Received</title>
|
||||||
<style>
|
<style>
|
||||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
|
/*
|
||||||
.container{max-width:640px;margin:0 auto;background:#1f2937;padding:1.5rem;border-radius:1rem;text-align:center}
|
Simple confirmation page shown after a customer submits a request.
|
||||||
h1{color:#34d399}
|
Gives them a request number they can reference at the booth.
|
||||||
.token{font-family:monospace;background:#111827;padding:.6rem;border-radius:.5rem;word-break:break-all}
|
*/
|
||||||
</style>
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.5;
|
||||||
|
display:flex;
|
||||||
|
justify-content:center;
|
||||||
|
align-items:center;
|
||||||
|
min-height:100vh;
|
||||||
|
}
|
||||||
|
.container{
|
||||||
|
max-width:640px;
|
||||||
|
margin:0 auto;
|
||||||
|
background:#1f2937;
|
||||||
|
padding:1.5rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
h1{color:#34d399;}
|
||||||
|
.note{font-size:.9rem;color:#9ca3af;}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>✅ Request Received</h1>
|
<h1>✅ Request Received</h1>
|
||||||
<p>Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.</p>
|
<p>Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.</p>
|
||||||
<p><strong>Your request number:</strong> #{{ req.id }}</p>
|
<p><strong>Your request number:</strong> #{{ req.id }}</p>
|
||||||
<p class="note">Bring this number to the booth if you want to check on progress.</p>
|
<p class="note">Bring this number to the booth if you want to check on progress.</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Reference in a new issue