Initial fork from theme-song-booth
This commit is contained in:
commit
99855b1212
33 changed files with 5723 additions and 0 deletions
45
.env.example
Normal file
45
.env.example
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# .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 and customer text (default Trollgorithm Theme Songs)
|
||||||
|
# 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)
|
||||||
|
# MAX_REVISIONS - default customer revision limit (default 2)
|
||||||
|
# HERMES_API_KEY - API key Hermes uses to POST prompts back (can be generated from /admin/settings)
|
||||||
|
# DATABASE - SQLite database path inside the container (default /app/data/booth.db)
|
||||||
|
# UPLOAD_FOLDER - directory for uploaded MP3s inside the container (default /app/uploads)
|
||||||
|
# SMTP_HOST - outgoing mail server (default mailroot8.namespro.ca)
|
||||||
|
# SMTP_PORT - outgoing mail server port (default 465)
|
||||||
|
# SMTP_USER - SMTP login username (default ai@hallsworth.ca)
|
||||||
|
# SMTP_FROM - From address for customer emails (default ai@hallsworth.ca)
|
||||||
|
|
||||||
|
APP_SECRET_KEY=change-me-in-production
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
|
SMTP_HOST=mailroot8.namespro.ca
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=ai@hallsworth.ca
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=ai@hallsworth.ca
|
||||||
|
ADMIN_ALERT_EMAIL=
|
||||||
|
PUBLIC_BASE_URL=http://127.0.0.1:5000
|
||||||
|
BOOTH_NAME=Trollgorithm Theme Songs
|
||||||
|
INTERNAL_PORT=8000
|
||||||
|
HOST_PORT=127.0.0.1:8000
|
||||||
|
PRICE_PER_VERSION=10.00
|
||||||
|
CURRENCY=CAD
|
||||||
|
MAX_REVISIONS=2
|
||||||
|
HERMES_API_KEY=
|
||||||
|
DATABASE=/app/data/booth.db
|
||||||
|
UPLOAD_FOLDER=/app/uploads
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
data/
|
||||||
|
uploads/
|
||||||
|
*.db
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
.DS_Store
|
||||||
30
.gitlab-ci.yml
Normal file
30
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
stages:
|
||||||
|
- check
|
||||||
|
- test
|
||||||
|
|
||||||
|
syntax:
|
||||||
|
stage: check
|
||||||
|
image: python:3.12-slim
|
||||||
|
before_script:
|
||||||
|
- pip install --no-cache-dir -r requirements.txt
|
||||||
|
script:
|
||||||
|
- python -m py_compile app.py models.py config.py init_db.py
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push"'
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||||
|
|
||||||
|
pytest:
|
||||||
|
stage: test
|
||||||
|
image: python:3.12-slim
|
||||||
|
before_script:
|
||||||
|
- pip install --no-cache-dir -r requirements.txt pytest
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
if find . -type f \( -name "test_*.py" -o -name "*_test.py" \) | grep -q .; then
|
||||||
|
pytest
|
||||||
|
else
|
||||||
|
echo "No tests found; skipping."
|
||||||
|
fi
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push"'
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||||
45
Dockerfile
Normal file
45
Dockerfile
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Environment: expects INTERNAL_PORT (default 8000) and the variables listed
|
||||||
|
# in config.py/.env.example to be supplied at runtime by docker-compose.
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install ffmpeg; clean apt cache to keep image small.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python requirements first for layer caching.
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application source, templates, static files, etc.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Avoid writing .pyc files and ensure stdout is unbuffered.
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Create a non-root user, persistent directories, and fix ownership.
|
||||||
|
RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app
|
||||||
|
USER boothuser
|
||||||
|
|
||||||
|
# Default internal port; override with INTERNAL_PORT env var.
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Use shell form so environment variables are expanded at runtime.
|
||||||
|
CMD gunicorn -b 0.0.0.0:${INTERNAL_PORT:-8000} --access-logfile - app:app
|
||||||
270
README.md
Normal file
270
README.md
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
# Theme Song Booth
|
||||||
|
|
||||||
|
**Version:** `v0.6.3`
|
||||||
|
|
||||||
|
A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate Suno prompts, upload MP3 previews, collect payment, and deliver final songs by email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
1. [What the booth does](#what-the-booth-does)
|
||||||
|
2. [Customer-facing pages](#customer-facing-pages)
|
||||||
|
3. [Operator / admin pages](#operator--admin-pages)
|
||||||
|
4. [Status flow](#status-flow)
|
||||||
|
5. [Settings page explained](#settings-page-explained)
|
||||||
|
6. [Docker installation](#docker-installation)
|
||||||
|
7. [Environment variables](#environment-variables)
|
||||||
|
8. [File layout](#file-layout)
|
||||||
|
9. [Local development](#local-development)
|
||||||
|
10. [Common troubleshooting](#common-troubleshooting)
|
||||||
|
11. [License / ownership](#license--ownership)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the booth does
|
||||||
|
|
||||||
|
1. A visitor fills out a short form at `/request`.
|
||||||
|
2. The operator reviews the request in the admin dashboard and generates a Suno Custom Mode prompt.
|
||||||
|
3. The operator (or an AI assistant via the `/api/prompt` callback) saves the prompt to the request.
|
||||||
|
4. The operator creates two song versions in Suno, downloads them, and uploads **Version A** and **Version B** to the request page.
|
||||||
|
5. The operator sends a preview email; the customer visits their private player page, listens to both versions, and either approves one/both or requests changes.
|
||||||
|
6. After the customer approves and pays, the operator records the payment reference and delivers the selected MP3(s) by email.
|
||||||
|
7. Optional stems / extras can be delivered via a share link that appears on the player page after delivery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customer-facing pages
|
||||||
|
|
||||||
|
| Page | Path | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
|
| Request form | `/request` | Visitors enter email, name, pronouns, hobbies, notable facts, and extra requests. The music style is picked from three dropdowns: **Decade** (required), **Basic style** (required), and **Additional style** (optional). Decades and genres are loaded from `/mnt/Storage/Decades.txt` and `/mnt/Storage/Music Genres.txt`. Rate limited to 5 submissions per minute per IP. |
|
||||||
|
| Closed page | `/request` (when booth is closed) | Shows a friendly closed banner instead of the form when the operator marks the booth closed. |
|
||||||
|
| Thanks | `/thanks/<id>` | Confirmation page shown after a request is submitted. |
|
||||||
|
| Order status | `/status` | Customers enter their email to see all their requests and statuses. |
|
||||||
|
| FAQ | `/faq` | Answers common customer questions. |
|
||||||
|
| Private player | `/play/<token>` | Secret link emailed to the customer. Streams Version A and B, lets them approve or request revisions, and later download delivered files / stems. |
|
||||||
|
| Kiosk | `/kiosk` | Public full-screen display for a booth tablet. Cycles between a QR code for `/request` and the configured price list. Updates automatically when pricing or booth state changes. |
|
||||||
|
|
||||||
|
### Style selection
|
||||||
|
|
||||||
|
The request form no longer has a free-text genre field. Instead, customers choose:
|
||||||
|
|
||||||
|
1. **Decade / era** — required (e.g. `1980's`).
|
||||||
|
2. **Basic style** — required (e.g. `Pop`).
|
||||||
|
3. **Additional style** — optional (e.g. `Funk`).
|
||||||
|
|
||||||
|
These are stored together in the `style_genre` column as a comma-separated string (e.g. `1980's, Pop, Funk`) so no schema change is required. The admin request page shows the same dropdowns for corrections, and the copy-to-Hermes prompt formats the style as a clean sentence like "1980's-era Pop with Funk influences" for better Suno results.
|
||||||
|
|
||||||
|
### Pronouns
|
||||||
|
|
||||||
|
A required **Pronouns** dropdown is shown just below the name field, with options:
|
||||||
|
|
||||||
|
- He/Him/His
|
||||||
|
- She/Her/Hers
|
||||||
|
- They/Them/Their
|
||||||
|
|
||||||
|
The selected pronouns are stored in the `pronouns` column and included in confirmation emails and Hermes prompt copy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operator / admin pages
|
||||||
|
|
||||||
|
| Page | Path | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
|
| Login | `/admin/login` | Simple session-based login. Password comes from the `ADMIN_PASSWORD` environment variable. |
|
||||||
|
| Dashboard | `/admin` | Main queue. Filter by status and auto-refresh at a configurable interval. |
|
||||||
|
| Request detail | `/admin/request/<id>` | Full control of one request: edit customer info (including pronouns and structured style), save prompt, copy Hermes callback, view revision history, upload MP3s, send preview, record payment, deliver files, add operator notes, and cancel. |
|
||||||
|
| Pricing | `/admin/pricing` | Configure fixed prices (one song, both songs, WAV per song, STEMs per song) and up to 5 custom items. |
|
||||||
|
| Sales | `/admin/sales` | Report of all delivered requests with customer details and Square payment references. |
|
||||||
|
| Settings | `/admin/settings` | Database health, backups, SMTP config, MP3 metadata defaults, revision limit, auto-refresh interval, kiosk mode, booth open/closed switch, Hermes API key management, and system reset. |
|
||||||
|
| Reset | `/admin/reset` | Clears all requests and uploaded files. Requires admin password confirmation. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status flow
|
||||||
|
|
||||||
|
```
|
||||||
|
pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `pending` | Customer submitted; waiting for a Suno prompt. |
|
||||||
|
| `prompt_ready` | Prompt saved; ready to generate songs. |
|
||||||
|
| `songs_uploaded` | Both MP3s uploaded; preview link can be sent. |
|
||||||
|
| `revisions_requested` | Customer asked for changes; current files archived. |
|
||||||
|
| `awaiting_payment` | Customer approved a version; waiting for payment. |
|
||||||
|
| `paid` | Payment recorded. |
|
||||||
|
| `delivered` | Final MP3(s) emailed to the customer. |
|
||||||
|
| `cancelled` | Request cancelled by the operator. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings page explained
|
||||||
|
|
||||||
|
The `/admin/settings` page is split into functional sections:
|
||||||
|
|
||||||
|
### Booth state
|
||||||
|
- **Booth open / closed** — When closed, `/request` and `/kiosk` show the closed banner.
|
||||||
|
|
||||||
|
### Hermes API key
|
||||||
|
- Displays whether a key is configured.
|
||||||
|
- **Regenerate API Key** creates a new random key stored in runtime settings.
|
||||||
|
- The key is used by the `/api/prompt/<id>` callback and by the `/api/key-test` diagnostic endpoint.
|
||||||
|
- Copy this key into your Hermes skill or AI assistant config.
|
||||||
|
|
||||||
|
### Customer revision limit
|
||||||
|
- Maximum number of times a customer can click **Request Changes** on the player page.
|
||||||
|
- Default is controlled by `MAX_REVISIONS` env var; can be overridden here.
|
||||||
|
|
||||||
|
### Dashboard refresh
|
||||||
|
- How often `/admin` reloads automatically (10, 20, or 30 seconds).
|
||||||
|
|
||||||
|
### Kiosk display
|
||||||
|
- **QR only** — shows the QR code permanently.
|
||||||
|
- **Pricing only** — shows the price list permanently.
|
||||||
|
- **Cycle every N seconds** — alternates between QR and pricing.
|
||||||
|
|
||||||
|
### SMTP settings
|
||||||
|
- Host, port, username, from address, and password for sending customer emails.
|
||||||
|
- The password is encrypted using `APP_SECRET_KEY` before being saved.
|
||||||
|
- **Send Test Email** verifies the configuration.
|
||||||
|
|
||||||
|
### MP3 metadata defaults
|
||||||
|
- Artist, album, year, and comment tags applied automatically to uploaded MP3s.
|
||||||
|
- The title tag is taken from the saved Suno prompt.
|
||||||
|
|
||||||
|
### Database maintenance
|
||||||
|
- **Health Check** — verifies all expected tables and columns exist.
|
||||||
|
- **Fix Database Schema** — adds missing tables/columns without deleting data.
|
||||||
|
- **Download Database Backup** — downloads the SQLite file.
|
||||||
|
- **Restore Database Backup** — replaces the live DB with an uploaded backup.
|
||||||
|
- **Download Uploads Backup** — ZIPs all uploaded MP3s for offline storage.
|
||||||
|
- **System Reset** — deletes all requests and uploaded files for a fresh event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker installation
|
||||||
|
|
||||||
|
### 1. Prepare environment variables
|
||||||
|
|
||||||
|
Generate values for the required secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the output for `APP_SECRET_KEY`.
|
||||||
|
|
||||||
|
### 2. Deploy with Portainer
|
||||||
|
|
||||||
|
1. Log in to Portainer.
|
||||||
|
2. Go to **Stacks** → **Add stack**.
|
||||||
|
3. Choose **Repository**:
|
||||||
|
- URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git`
|
||||||
|
- Branch: `main`
|
||||||
|
- Compose path: `docker-compose.yml`
|
||||||
|
4. Add the environment variables listed in the section below.
|
||||||
|
5. Deploy the stack.
|
||||||
|
6. Open a console in the running `theme-song-booth` container and run once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python init_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Point your reverse proxy at the host port you chose (default `127.0.0.1:8000`).
|
||||||
|
8. Visit `/admin/settings` and click **Regenerate API Key**.
|
||||||
|
9. Copy the key to your Hermes skill / AI assistant.
|
||||||
|
10. Print or display a QR code pointing to `https://your-domain/request`.
|
||||||
|
|
||||||
|
### Updating the deployment
|
||||||
|
|
||||||
|
After each push to Gitea:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Portainer → Stacks → theme-song-booth → Pull and redeploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Persistent volumes keep the database and uploads safe across redeploys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `APP_SECRET_KEY` | Yes | — | Long random string for Flask sessions and for encrypting stored settings such as the SMTP password and legacy stored API key. |
|
||||||
|
| `ADMIN_PASSWORD` | Yes | — | Password used to log in to `/admin`. |
|
||||||
|
| `SMTP_PASS` | Yes | — | Password for the SMTP account used to send customer emails. |
|
||||||
|
| `PUBLIC_BASE_URL` | Yes | — | Public HTTPS URL of the booth, e.g. `https://booth.dionysismedia.ca`. Used in player links, emails, and callback URLs. |
|
||||||
|
| `SMTP_HOST` | No | `mailroot8.namespro.ca` | SMTP server hostname. |
|
||||||
|
| `SMTP_PORT` | No | `465` | SMTP server port. |
|
||||||
|
| `SMTP_USER` | No | `ai@hallsworth.ca` | SMTP username. |
|
||||||
|
| `SMTP_FROM` | No | `ai@hallsworth.ca` | From address for customer emails. |
|
||||||
|
| `BOOTH_NAME` | No | `Trollgorithm Theme Songs` | Display name used in email subjects and page titles. |
|
||||||
|
| `HOST_PORT` | No | `127.0.0.1:8000` | Host-side `ip:port` mapping for the container. |
|
||||||
|
| `INTERNAL_PORT` | No | `8000` | Port gunicorn binds to inside the container. |
|
||||||
|
| `PRICE_PER_VERSION` | No | `10.00` | Legacy price label shown in some templates; current pricing is configured from `/admin/pricing`. |
|
||||||
|
| `CURRENCY` | No | `CAD` | Currency label shown with prices. |
|
||||||
|
| `MAX_REVISIONS` | No | `2` | Default customer revision limit before an operator override. |
|
||||||
|
| `HERMES_API_KEY` | No | — | API key for the `/api/prompt` callback. If omitted, generate one from `/admin/settings`. |
|
||||||
|
| `DATABASE` | No | `/app/data/booth.db` | Path to the SQLite database inside the container. |
|
||||||
|
| `UPLOAD_FOLDER` | No | `/app/uploads` | Path to uploaded MP3 storage inside the container. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `app.py` | Flask routes, helpers, email layer, runtime settings, MP3 tagging, rate limiting, database maintenance, Hermes callback, kiosk, pricing, and sales report. |
|
||||||
|
| `config.py` | Environment-variable based configuration with sensible defaults. |
|
||||||
|
| `models.py` | SQLite schema, CRUD helpers, and revision history. |
|
||||||
|
| `init_db.py` | Standalone script to create or migrate the database. |
|
||||||
|
| `templates/` | Jinja2 templates for customer pages, admin pages, and kiosk display. |
|
||||||
|
| `static/` | Banner images, closed banner, email logo, and kiosk QR code. |
|
||||||
|
| `lists/` | Bundled copies of `decades.txt` and `music_genres.txt` used as fallback for the style dropdowns. |
|
||||||
|
| `Dockerfile` | Production container image definition. |
|
||||||
|
| `docker-compose.yml` | Portainer stack definition. |
|
||||||
|
| `requirements.txt` | Python dependencies. |
|
||||||
|
| `.env.example` | Local development environment template. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jess/workspace/theme-song-booth
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env and set APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL
|
||||||
|
.venv/bin/python init_db.py
|
||||||
|
.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
|
||||||
|
- Kiosk: http://127.0.0.1:5000/kiosk
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common troubleshooting
|
||||||
|
|
||||||
|
| Problem | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| Emails not arriving | SMTP settings wrong or messages in spam. | Use **Send Test Email** on `/admin/settings`; verify host/port/password. |
|
||||||
|
| 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. |
|
||||||
|
| Logo missing from email | Logo file missing from `static/`. | Ensure `static/DM-Logo_email.png` is in the container. |
|
||||||
|
| Database schema mismatch | New column/table added but old DB not migrated. | Go to `/admin/settings` and click **Fix Database Schema**, or run `python init_db.py`. |
|
||||||
|
| Kiosk shows old prices | Page auto-refreshes every 30s; check `/admin/pricing`. | Verify pricing values and redeploy if templates changed. |
|
||||||
|
| `/api/prompt` returns 401 | Callback token expired or API key mismatch. | Copy a fresh callback URL from `/admin/request/<id>` and verify the key with `/api/key-test`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License / ownership
|
||||||
|
|
||||||
|
Built for Jess's Trollgorithm theme-song booth. All code and assets are private to that project.
|
||||||
154
REVIEW.md
Normal file
154
REVIEW.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# 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 Gitea repo
|
||||||
|
- SMTP (SSL port 465) for customer emails
|
||||||
|
- Square Terminal/Reader for manual payment
|
||||||
|
- `mutagen` for MP3 metadata tagging
|
||||||
|
- `flask-limiter` for public form rate limiting
|
||||||
|
- `cryptography` to encrypt the stored SMTP password
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
- Gitea: `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, runtime settings, MP3 tagging, rate limiting, DB health, kiosk route, pricing route, sales report, ZIP backup, and the `/api/prompt/<id>` Hermes callback endpoint. |
|
||||||
|
| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. `HERMES_API_KEY` can be overridden at runtime. |
|
||||||
|
| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. `revision_history` table tracks each customer revision. |
|
||||||
|
| `init_db.py` | Run once after deploy: `python init_db.py`. |
|
||||||
|
| `templates/admin/request.html` | Biggest template. Customer info editing, revision history, operator notes, Suno prompt fields, upload/delivery, cancel request button. |
|
||||||
|
| `templates/admin/dashboard.html` | Queue table + filters + auto-refresh + topbar links to Kiosk, Pricing, Sales, Settings. |
|
||||||
|
| `templates/admin/settings.html` | SMTP config, MP3 metadata defaults, DB backup/restore/health, reset, kiosk mode, Hermes API key display. |
|
||||||
|
| `templates/admin/pricing.html` | Fixed and custom pricing configuration. |
|
||||||
|
| `templates/admin/sales.html` | Sales report of delivered requests. |
|
||||||
|
| `templates/faq.html` | Customer FAQ page. |
|
||||||
|
| `templates/status.html` | Customer order status lookup. |
|
||||||
|
| `templates/closed.html` | Message shown on `/request` when the booth is marked closed. |
|
||||||
|
| `templates/kiosk.html` | Public full-screen display: open/closed banner, QR code, price list, auto-refresh. |
|
||||||
|
| `docker-compose.yml` | No `env_file`; variables come from Portainer. |
|
||||||
|
|
||||||
|
## Status meanings
|
||||||
|
|
||||||
|
```
|
||||||
|
pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch states:
|
||||||
|
|
||||||
|
- `revisions_requested` — customer asked for changes; current files are archived and a note is logged.
|
||||||
|
- `cancelled` — operator cancelled the request.
|
||||||
|
|
||||||
|
## Operator workflow
|
||||||
|
|
||||||
|
1. Customer fills `/request`.
|
||||||
|
2. Open `/admin`, click request row (or filter by status).
|
||||||
|
3. On `/admin/request/<id>`, fix customer info if needed, then click **Copy customer info for Hermes**, paste result to Hermes.
|
||||||
|
4. Hermes POSTs Title/Style/Lyrics back to the signed callback URL; the request becomes **Prompt Ready**.
|
||||||
|
5. If the callback fails, paste Hermes' response into the Title/Style/Lyrics fields and click **Save Prompt**.
|
||||||
|
6. Copy Lyrics, Style, Title into Suno Custom Mode in that order, generate two versions.
|
||||||
|
7. Upload Version A and B MP3s.
|
||||||
|
8. Click **Send Preview Link**.
|
||||||
|
9. Customer receives email, visits player, picks version.
|
||||||
|
10. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**.
|
||||||
|
11. 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
|
||||||
|
MAX_REVISIONS
|
||||||
|
HERMES_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Most can be overridden at runtime from `/admin/settings` and stored in `booth_settings.json`.
|
||||||
|
|
||||||
|
## Runtime settings stored in `booth_settings.json`
|
||||||
|
|
||||||
|
- SMTP host/port/user/from and encrypted password
|
||||||
|
- Dashboard auto-refresh interval
|
||||||
|
- Max revisions allowed per customer
|
||||||
|
- Booth open/closed state
|
||||||
|
- Default MP3 metadata tags (artist, album, year, comment)
|
||||||
|
- Hermes API key
|
||||||
|
- Pricing: fixed items (One Song, Both Songs, WAV per song, STEMs per song) and up to 5 custom named items
|
||||||
|
- Kiosk cycle mode (QR only, pricing only, or N seconds per slide)
|
||||||
|
|
||||||
|
These survive redeploys because `booth_settings.json` lives in the persistent uploads volume.
|
||||||
|
|
||||||
|
## 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`, then resets `sqlite_sequence`.
|
||||||
|
- Runtime settings are stored in the persistent uploads volume (`booth_settings.json`).
|
||||||
|
- The `booth_open` setting controls whether `/request` and `/kiosk` show the open banner or the closed banner.
|
||||||
|
- Container cannot read host paths; all static assets used at runtime (logo, banners, QR code) must be in `static/` or a mounted volume.
|
||||||
|
- The Hermes callback URL is signed with `APP_SECRET_KEY` and expires after 7 days.
|
||||||
|
- If you regenerate the Hermes API key, update the Hermes skill/config immediately; old key requests will 401.
|
||||||
|
- New columns/tables are added via `models.py`. Use `/admin/settings` → **Fix Database Schema** after redeploying a schema change.
|
||||||
|
- `__pycache__` and local `.env` files are already ignored by `.gitignore`; make sure they never get committed.
|
||||||
|
|
||||||
|
## How to redeploy
|
||||||
|
|
||||||
|
1. Push changes to Gitea `main`.
|
||||||
|
2. In Portainer: Stacks → `theme-song-booth` → **Pull and redeploy**.
|
||||||
|
3. If schema changed, open container console and run `python init_db.py`, or use `/admin/settings` → **Fix Database Schema**.
|
||||||
|
|
||||||
|
## Recent major additions
|
||||||
|
|
||||||
|
- **Structured style dropdowns** — customer form now uses Decade / Basic / Additional style dropdowns instead of a free-text genre field. Values are stored as a comma-separated string in `style_genre`.
|
||||||
|
- **Pronouns field** — required pronouns dropdown on the customer request form; stored in the `pronouns` column.
|
||||||
|
- **Lyrics in delivery email** — final delivery email includes the generated lyrics in the same format as the player page.
|
||||||
|
- **Delete uploaded songs** — admin request page can delete selected Version A / B uploads and reset the request to `prompt_ready`.
|
||||||
|
- **Live queue kiosk slide** — `/kiosk` can cycle through QR, pricing, and active-queue slides based on `kiosk_cycle_seconds`.
|
||||||
|
- **Cancelled status** — operators can mark any request as cancelled from the top of `/admin/request/<id>`.
|
||||||
|
- **Revision history log** — each customer revision is recorded with revision count, note, and archived file names.
|
||||||
|
- **Stems / Extras link** — operators paste a file-share link on the request page; customers see a download button after delivery.
|
||||||
|
- **Sales report** — `/admin/sales` lists all delivered requests with payment references.
|
||||||
|
- **Pricing page** — `/admin/pricing` configures fixed prices plus up to 5 custom items; used by `/kiosk`.
|
||||||
|
- **Public kiosk** — `/kiosk` is a full-screen tablet display with QR code and price list cycling.
|
||||||
|
- **Music ZIP backup** — `/admin/settings` can download all uploaded MP3s as a ZIP.
|
||||||
|
- **Database schema repair** — health check detects missing columns and tables and can repair them.
|
||||||
|
|
||||||
|
## Project state notes
|
||||||
|
|
||||||
|
- No `.gitlab-ci.yml` is currently in the repo; old pipeline records from an earlier CI config are still visible in Gitea but are not actionable because no runners are attached. Add a CI skeleton (see below) if you want automated checks back.
|
||||||
|
- No automated tests exist yet.
|
||||||
|
|
||||||
|
## CI skeleton (optional)
|
||||||
|
|
||||||
|
A **CI skeleton** is the smallest Gitea CI config that gives you useful automated checks on every push without needing a heavy test suite. For this project it would be a `.gitlab-ci.yml` with one or two jobs:
|
||||||
|
|
||||||
|
1. **Syntax check job** — install Python dependencies and run `python -m py_compile app.py models.py config.py init_db.py` to catch SyntaxErrors before they reach Portainer.
|
||||||
|
2. **(Optional) Test job** — run a minimal pytest suite once tests are written. Right now this would be a placeholder that skips if no tests exist, so the pipeline stays green while you decide whether to add tests.
|
||||||
|
|
||||||
|
It needs a Gitea runner to execute. Your Gitea instance has no runners attached, which is why the old pipelines are stuck/canceled. The skeleton just defines *what* to run; a runner is still required for it to actually execute.
|
||||||
|
|
||||||
|
## Static assets to keep in the repo
|
||||||
|
|
||||||
|
- `static/Trollgorithm_booth.jpg` — open banner (request page and kiosk)
|
||||||
|
- `static/Booth_closed.png` — closed banner
|
||||||
|
- `static/DM-Logo_email.png` — email signature logo
|
||||||
|
- `static/qr-code.png` — kiosk QR code pointing to `/request`
|
||||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0.6.3
|
||||||
99
config.py
Normal file
99
config.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""
|
||||||
|
config.py
|
||||||
|
=========
|
||||||
|
Configuration object loaded by Flask from environment variables.
|
||||||
|
|
||||||
|
Most operational settings are editable at runtime from /admin/settings and
|
||||||
|
stored in booth_settings.json on disk. Sensitive values (SMTP password) are
|
||||||
|
encrypted with the Flask SECRET_KEY when saved.
|
||||||
|
|
||||||
|
Environment variables (defaults shown):
|
||||||
|
Required:
|
||||||
|
- APP_SECRET_KEY long random string for Flask sessions and encryption
|
||||||
|
- ADMIN_PASSWORD plain-text password for /admin login
|
||||||
|
- PUBLIC_BASE_URL public URL customers use (e.g. https://booth.example.com)
|
||||||
|
- SMTP_PASS password for the SMTP account
|
||||||
|
Optional:
|
||||||
|
- BOOTH_NAME name in customer text and emails (default Trollgorithm Theme Songs)
|
||||||
|
- HOST_PORT docker-compose host-side port mapping (default 127.0.0.1:8000)
|
||||||
|
- INTERNAL_PORT gunicorn port inside the container (default 8000)
|
||||||
|
- MAX_REVISIONS default customer revision limit (default 2)
|
||||||
|
- PRICE_PER_VERSION price shown to customers (default 10.00)
|
||||||
|
- CURRENCY currency label (default CAD)
|
||||||
|
- DATABASE SQLite database path inside the container (default /app/data/booth.db)
|
||||||
|
- UPLOAD_FOLDER directory for uploaded MP3s inside the container (default /app/uploads)
|
||||||
|
- SETTINGS_FILE runtime settings JSON filename (default booth_settings.json)
|
||||||
|
- SMTP_HOST outgoing mail server (default mailroot8.namespro.ca)
|
||||||
|
- SMTP_PORT outgoing mail server port (default 465)
|
||||||
|
- SMTP_USER SMTP login username (default ai@hallsworth.ca)
|
||||||
|
- SMTP_FROM From address for customer emails (default ai@hallsworth.ca)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Load variables from .env file if present (development mode).
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_version():
|
||||||
|
"""Read the package version from the VERSION file next to this module."""
|
||||||
|
version_file = Path(__file__).parent / 'VERSION'
|
||||||
|
if version_file.exists():
|
||||||
|
return version_file.read_text().strip()
|
||||||
|
return '0.0.0'
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
# Flask secret key: used to sign session cookies and encrypt stored credentials.
|
||||||
|
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me')
|
||||||
|
|
||||||
|
# SQLite database path inside the container.
|
||||||
|
DATABASE = os.environ.get('DATABASE', '/app/data/booth.db')
|
||||||
|
|
||||||
|
# Directory where uploaded MP3 files are stored inside the container.
|
||||||
|
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
|
||||||
|
|
||||||
|
# Runtime settings file: stored next to the upload folder for persistence.
|
||||||
|
SETTINGS_FILE = os.environ.get('SETTINGS_FILE', 'booth_settings.json')
|
||||||
|
|
||||||
|
# Only MP3 uploads are allowed.
|
||||||
|
ALLOWED_EXTENSIONS = {'mp3'}
|
||||||
|
|
||||||
|
# Default SMTP server settings for sending customer emails.
|
||||||
|
# These can be overridden from /admin/settings and stored encrypted.
|
||||||
|
SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca')
|
||||||
|
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))
|
||||||
|
SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca')
|
||||||
|
SMTP_PASS = os.environ.get('SMTP_PASS', '')
|
||||||
|
SMTP_FROM = os.environ.get('SMTP_FROM', 'ai@hallsworth.ca')
|
||||||
|
|
||||||
|
# Admin login password (plain text, set via env).
|
||||||
|
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '')
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
|
||||||
|
# Default customer revision limit if not overridden in runtime settings.
|
||||||
|
MAX_REVISIONS = int(os.environ.get('MAX_REVISIONS', '2'))
|
||||||
|
|
||||||
|
# API key used by Hermes / an AI assistant to POST generated Suno prompts
|
||||||
|
# back to /api/prompt/<id>. If provided via env var it overrides the value
|
||||||
|
# stored in runtime settings. Stored encrypted when set from /admin/settings.
|
||||||
|
HERMES_API_KEY = os.environ.get('HERMES_API_KEY', '')
|
||||||
|
|
||||||
|
# Booth name used in customer-facing text and email sign-offs.
|
||||||
|
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')
|
||||||
|
|
||||||
|
# Internal port gunicorn listens on inside the container (also exposed in Dockerfile).
|
||||||
|
INTERNAL_PORT = int(os.environ.get('INTERNAL_PORT', '8000'))
|
||||||
|
|
||||||
|
# Legacy single-price label. Current pricing is configured per-item from
|
||||||
|
# /admin/pricing, but this value is still displayed in a few templates.
|
||||||
|
PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00'))
|
||||||
|
CURRENCY = os.environ.get('CURRENCY', 'CAD')
|
||||||
|
|
||||||
|
# Package version, read from VERSION file.
|
||||||
|
VERSION = _load_version()
|
||||||
50
docker-compose.yml
Normal file
50
docker-compose.yml
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# docker-compose.yml
|
||||||
|
# ==================
|
||||||
|
#
|
||||||
|
# Portainer stack definition.
|
||||||
|
# Builds the image directly from the Gitea 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.
|
||||||
|
#
|
||||||
|
# Required: APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL
|
||||||
|
# Optional: BOOTH_NAME, HOST_PORT, INTERNAL_PORT, PRICE_PER_VERSION,
|
||||||
|
# CURRENCY, MAX_REVISIONS, HERMES_API_KEY, DATABASE, UPLOAD_FOLDER,
|
||||||
|
# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM
|
||||||
|
#
|
||||||
|
# Named volumes keep the SQLite database and uploaded MP3s persistent
|
||||||
|
# across container restarts and redeploys.
|
||||||
|
|
||||||
|
services:
|
||||||
|
booth:
|
||||||
|
build:
|
||||||
|
context: https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git#main
|
||||||
|
container_name: theme-song-booth
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- APP_SECRET_KEY=${APP_SECRET_KEY}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
- SMTP_HOST=${SMTP_HOST:-mailroot8.namespro.ca}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-465}
|
||||||
|
- SMTP_USER=${SMTP_USER:-ai@hallsworth.ca}
|
||||||
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
|
- SMTP_FROM=${SMTP_FROM:-ai@hallsworth.ca}
|
||||||
|
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL}
|
||||||
|
- BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs}
|
||||||
|
- INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
||||||
|
- MAX_REVISIONS=${MAX_REVISIONS:-2}
|
||||||
|
- HERMES_API_KEY=${HERMES_API_KEY:-}
|
||||||
|
- PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00}
|
||||||
|
- CURRENCY=${CURRENCY:-CAD}
|
||||||
|
- DATABASE=${DATABASE:-/app/data/booth.db}
|
||||||
|
- UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads}
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-127.0.0.1:8000}:${INTERNAL_PORT:-8000}"
|
||||||
|
volumes:
|
||||||
|
- booth-data:/app/data
|
||||||
|
- booth-uploads:/app/uploads
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
booth-data:
|
||||||
|
booth-uploads:
|
||||||
502
helpers.py
Normal file
502
helpers.py
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
"""
|
||||||
|
helpers.py
|
||||||
|
==========
|
||||||
|
Utility and configuration helpers for the Theme Song Booth Flask app.
|
||||||
|
|
||||||
|
These functions are stateless (or use Flask's current_app / session context)
|
||||||
|
and are imported by app.py. Keeping them here reduces the size of the route file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import json
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import session, current_app, flash
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Genre / decade helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_GENRES_PATH = Path('/mnt/Storage/Music Genres.txt')
|
||||||
|
_DECADES_PATH = Path('/mnt/Storage/Decades.txt')
|
||||||
|
_FALLBACK_GENRES_PATH = Path(__file__).parent / 'lists' / 'music_genres.txt'
|
||||||
|
_FALLBACK_DECADES_PATH = Path(__file__).parent / 'lists' / 'decades.txt'
|
||||||
|
|
||||||
|
|
||||||
|
def _load_lines(path: Path) -> list[str]:
|
||||||
|
"""Load a text file and return non-empty stripped lines."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
lines = path.read_text(encoding='utf-8').splitlines()
|
||||||
|
return [line.strip() for line in lines if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_list(primary: Path, fallback: Path) -> list[str]:
|
||||||
|
"""Load from the primary path, falling back to the bundled copy."""
|
||||||
|
lines = _load_lines(primary)
|
||||||
|
if lines:
|
||||||
|
return lines
|
||||||
|
return _load_lines(fallback)
|
||||||
|
|
||||||
|
|
||||||
|
MUSIC_GENRES = _load_list(_GENRES_PATH, _FALLBACK_GENRES_PATH)
|
||||||
|
DECADES = _load_list(_DECADES_PATH, _FALLBACK_DECADES_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_style_genre(style_genre: str | None) -> dict:
|
||||||
|
"""
|
||||||
|
Split a stored combined style string into decade, basic, and additional.
|
||||||
|
The stored format is 'Decade, Basic, Additional' (additional may be empty).
|
||||||
|
"""
|
||||||
|
parts = [p.strip() for p in (style_genre or '').split(',') if p.strip()]
|
||||||
|
return {
|
||||||
|
'decade': parts[0] if len(parts) > 0 else '',
|
||||||
|
'basic_style': parts[1] if len(parts) > 1 else '',
|
||||||
|
'additional_style': ', '.join(parts[2:]) if len(parts) > 2 else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_style_genre(decade: str, basic_style: str, additional_style: str) -> str:
|
||||||
|
"""Build the combined style_genre string stored in the database."""
|
||||||
|
parts = [p.strip() for p in [decade, basic_style, additional_style] if p.strip()]
|
||||||
|
return ', '.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth / validation helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def is_admin():
|
||||||
|
"""Return True if the current browser session is logged in as admin."""
|
||||||
|
return session.get('admin') is True
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin():
|
||||||
|
"""Redirect to the admin login page if the user is not logged in."""
|
||||||
|
from flask import redirect, url_for
|
||||||
|
if not is_admin():
|
||||||
|
return redirect(url_for('admin_login'))
|
||||||
|
|
||||||
|
|
||||||
|
def admin_password_ok(pw):
|
||||||
|
"""Check the submitted admin password against the configured one."""
|
||||||
|
return pw and pw == current_app.config['ADMIN_PASSWORD']
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_email(email):
|
||||||
|
"""Return True if the given string looks like a valid email address."""
|
||||||
|
if not email:
|
||||||
|
return False
|
||||||
|
pattern = r"^[^\s@]+@[^\s@]+\.[^\s@]+$"
|
||||||
|
return re.match(pattern, email) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File upload helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
"""Return True if the uploaded filename has an allowed extension (mp3)."""
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
|
||||||
|
|
||||||
|
|
||||||
|
def upload_path(request_id):
|
||||||
|
"""Return the per-request upload directory path, creating it if necessary."""
|
||||||
|
p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id)
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def save_upload(request_id, file_obj, version, song_title=None):
|
||||||
|
"""
|
||||||
|
Save an uploaded MP3 file for a request, preserving the original filename
|
||||||
|
with a version prefix. Applies the configured metadata tags.
|
||||||
|
"""
|
||||||
|
if not file_obj or file_obj.filename == '':
|
||||||
|
return None
|
||||||
|
if not allowed_file(file_obj.filename):
|
||||||
|
flash('Only MP3 files are allowed.', 'error')
|
||||||
|
return None
|
||||||
|
original = secure_filename(file_obj.filename)
|
||||||
|
filename = f"{version.upper()} - {original}"
|
||||||
|
p = upload_path(request_id)
|
||||||
|
dest = p / filename
|
||||||
|
file_obj.save(dest)
|
||||||
|
apply_mp3_tags(str(dest), song_title)
|
||||||
|
return str(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_mp3_tags(path, title=None):
|
||||||
|
"""
|
||||||
|
Write common ID3 tags on an uploaded MP3 using the runtime metadata defaults.
|
||||||
|
Failures are logged as a warning and do not block the upload.
|
||||||
|
"""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
try:
|
||||||
|
audio = MP3(path)
|
||||||
|
if audio.tags is None:
|
||||||
|
audio.add_tags()
|
||||||
|
if not isinstance(audio.tags, EasyID3):
|
||||||
|
audio.tags = EasyID3()
|
||||||
|
tags = audio.tags
|
||||||
|
if title:
|
||||||
|
tags['title'] = title
|
||||||
|
if cfg.get('artist'):
|
||||||
|
tags['artist'] = cfg['artist']
|
||||||
|
if cfg.get('album'):
|
||||||
|
tags['album'] = cfg['album']
|
||||||
|
if cfg.get('year'):
|
||||||
|
tags['date'] = str(cfg['year'])
|
||||||
|
audio.save()
|
||||||
|
if cfg.get('comment'):
|
||||||
|
from mutagen.id3 import COMM, TXXX
|
||||||
|
audio2 = MP3(path)
|
||||||
|
if audio2.tags is None:
|
||||||
|
audio2.add_tags()
|
||||||
|
audio2.tags["COMM"] = COMM(encoding=3, lang='eng', desc='Comment', text=cfg['comment'])
|
||||||
|
audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment'])
|
||||||
|
audio2.save()
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
flash(f'Warning: could not tag MP3: {e}', 'error')
|
||||||
|
except RuntimeError:
|
||||||
|
import logging
|
||||||
|
logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Encryption / settings helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_fernet():
|
||||||
|
"""Derive a Fernet key from the Flask SECRET_KEY so stored values are encrypted."""
|
||||||
|
secret = current_app.config['SECRET_KEY'].encode()
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=b'theme-song-booth-v1',
|
||||||
|
iterations=480000,
|
||||||
|
)
|
||||||
|
key = base64.urlsafe_b64encode(kdf.derive(secret))
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(value):
|
||||||
|
"""Encrypt a string using the Flask SECRET_KEY. Returns base64 ciphertext."""
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
return _get_fernet().encrypt(value.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(ciphertext):
|
||||||
|
"""Decrypt a string previously encrypted by encrypt_value."""
|
||||||
|
if not ciphertext:
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
return _get_fernet().decrypt(ciphertext.encode()).decode()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value_legacy(ciphertext):
|
||||||
|
"""Decrypt or return plaintext. Tolerates unencrypted legacy values."""
|
||||||
|
if not ciphertext:
|
||||||
|
return ''
|
||||||
|
plaintext = decrypt_value(ciphertext)
|
||||||
|
if plaintext:
|
||||||
|
return plaintext
|
||||||
|
if not ciphertext.endswith('='):
|
||||||
|
return ciphertext
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def settings_file_path():
|
||||||
|
"""Return the path to the persistent runtime settings JSON file."""
|
||||||
|
return Path(current_app.config['DATABASE']).parent / current_app.config['SETTINGS_FILE']
|
||||||
|
|
||||||
|
|
||||||
|
def load_booth_settings():
|
||||||
|
"""Load persistent runtime settings from JSON file inside the upload parent."""
|
||||||
|
cfg_path = settings_file_path()
|
||||||
|
if cfg_path.exists():
|
||||||
|
try:
|
||||||
|
cfg = json.loads(cfg_path.read_text())
|
||||||
|
for key in ('artist', 'album', 'year', 'comment', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from'):
|
||||||
|
if cfg.get(key) is None:
|
||||||
|
cfg[key] = ''
|
||||||
|
return cfg
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_booth_settings(settings):
|
||||||
|
"""Persist runtime settings to JSON file."""
|
||||||
|
cfg_path = settings_file_path()
|
||||||
|
try:
|
||||||
|
cfg_path.write_text(json.dumps(settings, indent=2))
|
||||||
|
except OSError as e:
|
||||||
|
flash(f'Warning: could not save settings: {e}', 'error')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config getters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_email_config():
|
||||||
|
"""Return the effective SMTP configuration."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
return {
|
||||||
|
'SMTP_HOST': cfg.get('smtp_host', current_app.config['SMTP_HOST']),
|
||||||
|
'SMTP_PORT': int(cfg.get('smtp_port') or current_app.config['SMTP_PORT']),
|
||||||
|
'SMTP_USER': cfg.get('smtp_user', current_app.config['SMTP_USER']),
|
||||||
|
'SMTP_PASS': decrypt_value(cfg.get('smtp_pass', '')) or current_app.config['SMTP_PASS'],
|
||||||
|
'SMTP_FROM': cfg.get('smtp_from', current_app.config['SMTP_FROM']),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_refresh_seconds():
|
||||||
|
"""Return the dashboard auto-refresh interval in seconds (10, 20, or 30)."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
try:
|
||||||
|
val = int(cfg.get('refresh_seconds', 10))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = 10
|
||||||
|
return val if val in (10, 20, 30) else 10
|
||||||
|
|
||||||
|
|
||||||
|
def get_kiosk_cycle_seconds():
|
||||||
|
"""Return the kiosk slide cycle interval in seconds."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
try:
|
||||||
|
val = int(cfg.get('kiosk_cycle_seconds', 10))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = 10
|
||||||
|
if val == 0:
|
||||||
|
return 0
|
||||||
|
return max(5, val)
|
||||||
|
|
||||||
|
|
||||||
|
def get_kiosk_mode():
|
||||||
|
"""Return 'qr', 'prices', 'queue', or 'cycle' based on kiosk_cycle_seconds setting."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
try:
|
||||||
|
val = int(cfg.get('kiosk_cycle_seconds', 10))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = 10
|
||||||
|
if val == -1:
|
||||||
|
return 'qr'
|
||||||
|
if val == 0:
|
||||||
|
return 'prices'
|
||||||
|
if val == 1:
|
||||||
|
return 'queue'
|
||||||
|
return 'cycle'
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_revisions():
|
||||||
|
"""Return the effective max revisions as an integer."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
try:
|
||||||
|
val = int(cfg.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = current_app.config.get('MAX_REVISIONS', 2)
|
||||||
|
return max(0, val)
|
||||||
|
|
||||||
|
|
||||||
|
def get_callback_expiry_hours():
|
||||||
|
"""Return the Hermes signed callback token lifetime in hours (default 168 = 7 days)."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
try:
|
||||||
|
val = int(cfg.get('callback_expiry_hours', 168))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = 168
|
||||||
|
return max(1, val)
|
||||||
|
|
||||||
|
|
||||||
|
def get_hermes_api_key():
|
||||||
|
"""Return the effective Hermes API key."""
|
||||||
|
env_key = current_app.config.get('HERMES_API_KEY', '')
|
||||||
|
if env_key:
|
||||||
|
return env_key
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
return decrypt_value_legacy(cfg.get('hermes_api_key', ''))
|
||||||
|
|
||||||
|
|
||||||
|
def set_hermes_api_key(key):
|
||||||
|
"""Persist a new Hermes API key (encrypted) to runtime settings."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
cfg['hermes_api_key'] = encrypt_value(key)
|
||||||
|
save_booth_settings(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_hermes_api_key():
|
||||||
|
"""Generate a new random API key for Hermes callback authentication."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def mask_api_key(key):
|
||||||
|
"""Return a masked version of the API key showing only the last 6 characters."""
|
||||||
|
if not key:
|
||||||
|
return 'Not set'
|
||||||
|
if len(key) <= 6:
|
||||||
|
return '*' * len(key)
|
||||||
|
return '*' * (len(key) - 6) + key[-6:]
|
||||||
|
|
||||||
|
|
||||||
|
def get_ntfy_config():
|
||||||
|
"""Return the effective ntfy server URL, topic, and access token from runtime settings."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
return {
|
||||||
|
'server': cfg.get('ntfy_server', ''),
|
||||||
|
'topic': cfg.get('ntfy_topic', ''),
|
||||||
|
'token': decrypt_value(cfg.get('ntfy_token', '')) or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell'):
|
||||||
|
"""Send a push notification to the configured ntfy topic, if configured."""
|
||||||
|
ntfy = get_ntfy_config()
|
||||||
|
server = ntfy.get('server', '').rstrip('/')
|
||||||
|
topic = ntfy.get('topic', '').strip()
|
||||||
|
if not server or not topic:
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = f"{server}/{topic}"
|
||||||
|
headers = {
|
||||||
|
'Title': title,
|
||||||
|
'Priority': priority,
|
||||||
|
'Tags': tags,
|
||||||
|
}
|
||||||
|
token = ntfy.get('token', '')
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=10)
|
||||||
|
return resp.status_code in (200, 202)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signed callback helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def sign_prompt_callback(rid, expires_at=None):
|
||||||
|
"""
|
||||||
|
Create a signed callback token for a specific request ID.
|
||||||
|
The signature is HMAC-SHA256 over "rid:expires_at" using APP_SECRET_KEY.
|
||||||
|
"""
|
||||||
|
secret = current_app.config['SECRET_KEY'].encode()
|
||||||
|
if expires_at is None:
|
||||||
|
expires_at = int(time.time()) + get_callback_expiry_hours() * 3600
|
||||||
|
payload = f"{rid}:{expires_at}"
|
||||||
|
sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()[:16]
|
||||||
|
return f"{rid}:{expires_at}:{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_prompt_callback(token):
|
||||||
|
"""Verify a signed callback token. Returns (rid, ok) tuple."""
|
||||||
|
if not token:
|
||||||
|
return None, False
|
||||||
|
parts = token.split(':')
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None, False
|
||||||
|
try:
|
||||||
|
rid = int(parts[0])
|
||||||
|
expires_at = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return None, False
|
||||||
|
if int(time.time()) > expires_at:
|
||||||
|
return None, False
|
||||||
|
expected = sign_prompt_callback(rid, expires_at)
|
||||||
|
if not hmac.compare_digest(expected, token):
|
||||||
|
return None, False
|
||||||
|
return rid, True
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt_callback_url(rid):
|
||||||
|
"""Build the full callback URL an operator pastes into Hermes for a request."""
|
||||||
|
from flask import url_for
|
||||||
|
token = sign_prompt_callback(rid)
|
||||||
|
return f"{current_app.config['PUBLIC_BASE_URL']}/api/prompt/{rid}?token={token}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Email helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def send_email(to, subject, body, attachments=None, inline_images=None):
|
||||||
|
"""Send an email using the configured or runtime SMTP settings."""
|
||||||
|
cfg = get_email_config()
|
||||||
|
if not cfg['SMTP_PASS']:
|
||||||
|
raise RuntimeError('SMTP password is not configured')
|
||||||
|
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg['From'] = cfg['SMTP_FROM']
|
||||||
|
msg['To'] = to
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg.set_content(body)
|
||||||
|
|
||||||
|
html_body = body.replace('\n', '<br>\n')
|
||||||
|
if inline_images:
|
||||||
|
for _, cid in inline_images:
|
||||||
|
html_body += f'<br><img src="cid:{cid}" alt="Dionysis Media" style="max-width:200px;margin-top:1rem;"/>'
|
||||||
|
html_body += f'<br><br><hr style="border:none;border-top:1px solid #ddd;"/><p style="font-size:0.9rem;color:#555;">Dionysis Media: stories, sound, and a little divine chaos — <a href="https://dionysismedia.ca/">https://dionysismedia.ca/</a></p>'
|
||||||
|
msg.add_alternative(html_body, subtype='html')
|
||||||
|
|
||||||
|
if inline_images:
|
||||||
|
for path, cid in inline_images:
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
ext = Path(path).suffix.lower().lstrip('.')
|
||||||
|
subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png'
|
||||||
|
msg.get_payload()[1].add_related(data, maintype='image', subtype=subtype, cid=f'<{cid}>')
|
||||||
|
|
||||||
|
if attachments:
|
||||||
|
for path, name in attachments:
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
msg.add_attachment(data, maintype='audio', subtype='mpeg', filename=name)
|
||||||
|
|
||||||
|
with smtplib.SMTP_SSL(cfg['SMTP_HOST'], cfg['SMTP_PORT'], context=ssl.create_default_context()) as server:
|
||||||
|
server.login(cfg['SMTP_USER'], cfg['SMTP_PASS'])
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def build_signature_images():
|
||||||
|
"""Return inline image tuple list for static/DM-Logo_email.png."""
|
||||||
|
logo_path = Path(current_app.root_path) / 'static' / 'DM-Logo_email.png'
|
||||||
|
if not logo_path.exists():
|
||||||
|
return []
|
||||||
|
return [(str(logo_path), 'dm-logo')]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Booth state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_booth_open():
|
||||||
|
"""Return True if the booth is currently marked as open in runtime settings."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
return cfg.get('booth_open', True)
|
||||||
25
init_db.py
Normal file
25
init_db.py
Normal file
|
|
@ -0,0 +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 registered Flask CLI command:
|
||||||
|
flask --app app init-db
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ensure the project root is on sys.path so `from app import app` works.
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from app import app
|
||||||
|
from models import init_db
|
||||||
|
|
||||||
|
# Use the configured database path and create tables.
|
||||||
|
with app.app_context():
|
||||||
|
init_db()
|
||||||
|
print(f"Database initialized at {app.config['DATABASE']}")
|
||||||
10
lists/decades.txt
Executable file
10
lists/decades.txt
Executable file
|
|
@ -0,0 +1,10 @@
|
||||||
|
Early 20th Centrury
|
||||||
|
1940's
|
||||||
|
1950's
|
||||||
|
1960's
|
||||||
|
1970's
|
||||||
|
1980's
|
||||||
|
1990's
|
||||||
|
2000's
|
||||||
|
2010's
|
||||||
|
Modern
|
||||||
190
lists/music_genres.txt
Executable file
190
lists/music_genres.txt
Executable file
|
|
@ -0,0 +1,190 @@
|
||||||
|
A Cappella
|
||||||
|
Abstract
|
||||||
|
Acid
|
||||||
|
Acid Jazz
|
||||||
|
Acid Punk
|
||||||
|
Acoustic
|
||||||
|
Afro-Punk
|
||||||
|
Alternative
|
||||||
|
Alternative Rock
|
||||||
|
Ambient
|
||||||
|
Anime
|
||||||
|
Art Rock
|
||||||
|
Avantgarde
|
||||||
|
Ballad
|
||||||
|
Baroque
|
||||||
|
Bass
|
||||||
|
Beat
|
||||||
|
Bebop
|
||||||
|
Bhangra
|
||||||
|
Big Band
|
||||||
|
Big Beat
|
||||||
|
Black Metal
|
||||||
|
Bluegrass
|
||||||
|
Blues
|
||||||
|
Booty Bass
|
||||||
|
Breakbeat
|
||||||
|
BritPop
|
||||||
|
Cabaret
|
||||||
|
Celtic
|
||||||
|
Chamber Music
|
||||||
|
Chanson
|
||||||
|
Chillout
|
||||||
|
Chorus
|
||||||
|
Christian Gangsta Rap
|
||||||
|
Christian Rap
|
||||||
|
Christian Rock
|
||||||
|
Classic Rock
|
||||||
|
Classical
|
||||||
|
Club
|
||||||
|
Club-House
|
||||||
|
Comedy
|
||||||
|
Contemporary Christian
|
||||||
|
Country
|
||||||
|
Crossover
|
||||||
|
Cult
|
||||||
|
Dance
|
||||||
|
Dance Hall
|
||||||
|
Darkwave
|
||||||
|
Death Metal
|
||||||
|
Disco
|
||||||
|
Downtempo
|
||||||
|
Dream
|
||||||
|
Drum & Bass
|
||||||
|
Drum Solo
|
||||||
|
Dub
|
||||||
|
Dubstep
|
||||||
|
Duet
|
||||||
|
Easy Listening
|
||||||
|
EBM
|
||||||
|
Eclectic
|
||||||
|
Electro
|
||||||
|
Electroclash
|
||||||
|
Electronic
|
||||||
|
Emo
|
||||||
|
Ethnic
|
||||||
|
Euro-House
|
||||||
|
Euro-Techno
|
||||||
|
Eurodance
|
||||||
|
Experimental
|
||||||
|
Fast Fusion
|
||||||
|
Folk
|
||||||
|
Folk-Rock
|
||||||
|
Folklore
|
||||||
|
Freestyle
|
||||||
|
Funk
|
||||||
|
Fusion
|
||||||
|
G-Funk
|
||||||
|
Game
|
||||||
|
Gangsta
|
||||||
|
Garage
|
||||||
|
Garage Rock
|
||||||
|
Global
|
||||||
|
Goa
|
||||||
|
Gospel
|
||||||
|
Gothic
|
||||||
|
Gothic Rock
|
||||||
|
Grunge
|
||||||
|
Hard Rock
|
||||||
|
Hardcore
|
||||||
|
Heavy Metal
|
||||||
|
Hip-Hop
|
||||||
|
House
|
||||||
|
Humour
|
||||||
|
IDM
|
||||||
|
Illbient
|
||||||
|
Indie
|
||||||
|
Indie Rock
|
||||||
|
Industrial
|
||||||
|
Industro-Goth
|
||||||
|
Instrumental
|
||||||
|
Instrumental Pop
|
||||||
|
Instrumental Rock
|
||||||
|
Jam Band
|
||||||
|
Jazz
|
||||||
|
Jazz & Funk
|
||||||
|
JPop
|
||||||
|
Jungle
|
||||||
|
Krautrock
|
||||||
|
Latin
|
||||||
|
Leftfield
|
||||||
|
Lo-Fi
|
||||||
|
Lounge
|
||||||
|
Math Rock
|
||||||
|
Mariachi
|
||||||
|
Meditative
|
||||||
|
Merengue
|
||||||
|
Metal
|
||||||
|
Musical
|
||||||
|
National Folk
|
||||||
|
Native American
|
||||||
|
Neoclassical
|
||||||
|
Neue Deutsche Welle
|
||||||
|
New Age
|
||||||
|
New Romantic
|
||||||
|
New Wave
|
||||||
|
Noise
|
||||||
|
Nu-Breakz
|
||||||
|
Oldies
|
||||||
|
Opera
|
||||||
|
Podcast
|
||||||
|
Polka
|
||||||
|
Polsk Punk
|
||||||
|
Pop
|
||||||
|
Pop-Folk
|
||||||
|
Pop/Funk
|
||||||
|
Porn Groove
|
||||||
|
Post-Punk
|
||||||
|
Post-Rock
|
||||||
|
Power Ballad
|
||||||
|
Pranks
|
||||||
|
Primus
|
||||||
|
Progressive Rock
|
||||||
|
Psybient
|
||||||
|
Psychedelic
|
||||||
|
Psychedelic Rock
|
||||||
|
Psytrance
|
||||||
|
Punk
|
||||||
|
Punk Rock
|
||||||
|
Rap
|
||||||
|
Rave
|
||||||
|
Reggae
|
||||||
|
Retro
|
||||||
|
Revival
|
||||||
|
Rhythm and Blues
|
||||||
|
Rhythmic Soul
|
||||||
|
Rock
|
||||||
|
Rock & Roll
|
||||||
|
Salsa
|
||||||
|
Samba
|
||||||
|
Satire
|
||||||
|
Shoegaze
|
||||||
|
Showtunes
|
||||||
|
Ska
|
||||||
|
Slow Jam
|
||||||
|
Slow Rock
|
||||||
|
Sonata
|
||||||
|
Soul
|
||||||
|
Sound Clip
|
||||||
|
Soundtrack
|
||||||
|
Southern Rock
|
||||||
|
Space
|
||||||
|
Space Rock
|
||||||
|
Speech
|
||||||
|
Swing
|
||||||
|
Symphonic Rock
|
||||||
|
Symphony
|
||||||
|
Synthpop
|
||||||
|
Tango
|
||||||
|
Techno
|
||||||
|
Techno-Industrial
|
||||||
|
Terror
|
||||||
|
Thrash Metal
|
||||||
|
Top 40
|
||||||
|
Trailer
|
||||||
|
Trance
|
||||||
|
Tribal
|
||||||
|
Trip-Hop
|
||||||
|
Trop Rock
|
||||||
|
Vocal
|
||||||
|
World Music
|
||||||
257
models.py
Normal file
257
models.py
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
"""
|
||||||
|
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, player token,
|
||||||
|
vocal gender preference, revision count, revision note, and operator notes.
|
||||||
|
- Revisions: when a customer requests changes, the current A/B MP3 files are
|
||||||
|
renamed to archived "RevN-" copies and new versions are uploaded later.
|
||||||
|
- operator_notes is an internal column for the booth team and is never shown
|
||||||
|
to customers.
|
||||||
|
- Indexes on status and player_token for fast queue/lookup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import current_app, g
|
||||||
|
|
||||||
|
# SQL executed by init_db() to create the requests table and indexes.
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
hobbies TEXT,
|
||||||
|
notable_facts TEXT,
|
||||||
|
style_genre TEXT,
|
||||||
|
pronouns TEXT,
|
||||||
|
extra_requests TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
suno_title TEXT,
|
||||||
|
suno_style TEXT,
|
||||||
|
suno_lyrics TEXT,
|
||||||
|
song_a_path TEXT,
|
||||||
|
song_b_path TEXT,
|
||||||
|
vocal_gender TEXT,
|
||||||
|
customer_approved TEXT DEFAULT 'none',
|
||||||
|
approval_notified_at TIMESTAMP,
|
||||||
|
preview_sent_at TIMESTAMP,
|
||||||
|
delivery_sent_at TIMESTAMP,
|
||||||
|
square_payment_ref TEXT,
|
||||||
|
admin_alert_email TEXT, -- reserved for future operator alerts; currently unused
|
||||||
|
player_token TEXT NOT NULL UNIQUE,
|
||||||
|
revision_count INTEGER DEFAULT 0,
|
||||||
|
revision_note TEXT,
|
||||||
|
operator_notes TEXT,
|
||||||
|
stems_link TEXT,
|
||||||
|
stems_interest INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS revision_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
request_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revision_count INTEGER NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
old_song_a_path TEXT,
|
||||||
|
old_song_b_path TEXT,
|
||||||
|
new_song_a_path TEXT,
|
||||||
|
new_song_b_path TEXT,
|
||||||
|
FOREIGN KEY (request_id) REFERENCES requests(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_revision_history_request ON revision_history(request_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Get or create a SQLite connection tied to the current Flask request context."""
|
||||||
|
if 'db' not in g:
|
||||||
|
g.db = sqlite3.connect(current_app.config['DATABASE'])
|
||||||
|
g.db.row_factory = sqlite3.Row
|
||||||
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
|
def close_db(e=None):
|
||||||
|
"""Close the request-scoped SQLite connection. Registered as teardown handler."""
|
||||||
|
db = g.pop('db', None)
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Create the database file and tables, adding any missing columns to existing tables."""
|
||||||
|
db_path = current_app.config['DATABASE']
|
||||||
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
db = sqlite3.connect(db_path)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
db.executescript(SCHEMA)
|
||||||
|
|
||||||
|
# SQLite ALTER TABLE is limited; add newer columns if they are missing.
|
||||||
|
expected_columns = {
|
||||||
|
'requests': [
|
||||||
|
'id', 'created_at', 'name', 'email', 'hobbies', 'notable_facts',
|
||||||
|
'style_genre', 'pronouns', 'extra_requests', 'status', 'suno_title', 'suno_style',
|
||||||
|
'suno_lyrics', 'song_a_path', 'song_b_path', 'vocal_gender',
|
||||||
|
'customer_approved', 'approval_notified_at', 'preview_sent_at',
|
||||||
|
'delivery_sent_at', 'square_payment_ref', 'admin_alert_email',
|
||||||
|
'player_token', 'revision_count', 'revision_note', 'operator_notes',
|
||||||
|
'stems_link', 'stems_interest'
|
||||||
|
],
|
||||||
|
'revision_history': [
|
||||||
|
'id', 'created_at', 'request_id', 'revision_count', 'note',
|
||||||
|
'old_song_a_path', 'old_song_b_path', 'new_song_a_path', 'new_song_b_path'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
for table, columns in expected_columns.items():
|
||||||
|
existing = {r['name'] for r in db.execute(f"PRAGMA table_info({table})")}
|
||||||
|
for col in columns:
|
||||||
|
if col not in existing:
|
||||||
|
# revision_count must be INTEGER so arithmetic in app.py works.
|
||||||
|
col_type = 'INTEGER' if col == 'revision_count' else 'TEXT'
|
||||||
|
db.execute(f'ALTER TABLE {table} ADD COLUMN {col} {col_type}')
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def new_token():
|
||||||
|
"""Generate a URL-safe random token used for private player links."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc():
|
||||||
|
"""Return current UTC time as ISO-8601 string for timestamp columns."""
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests, vocal_gender=None, pronouns=None, stems_interest=0):
|
||||||
|
"""
|
||||||
|
Insert a new customer request.
|
||||||
|
Returns the auto-generated request id.
|
||||||
|
"""
|
||||||
|
db = get_db()
|
||||||
|
cur = db.execute(
|
||||||
|
"""INSERT INTO requests
|
||||||
|
(name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, player_token, stems_interest)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, new_token(), 1 if stems_interest else 0)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_by_id(request_id):
|
||||||
|
"""Fetch one request by numeric id. Returns dict or None."""
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute('SELECT * FROM requests WHERE id = ?', (request_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_by_token(token):
|
||||||
|
"""Fetch one request by its private player token. Returns dict or None."""
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute('SELECT * FROM requests WHERE player_token = ?', (token,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_requests(status=None):
|
||||||
|
"""List all requests, optionally filtered by status, newest first.
|
||||||
|
Normalizes whitespace in the status parameter so URLs like "Needs Upload"
|
||||||
|
match the stored value."""
|
||||||
|
db = get_db()
|
||||||
|
if status:
|
||||||
|
# Translate human filter names to stored status values.
|
||||||
|
status = status.lower().replace(' ', '_')
|
||||||
|
rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute('SELECT * FROM requests ORDER BY created_at DESC').fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_requests_by_email(email):
|
||||||
|
"""Fetch all requests for a given email address, newest first.
|
||||||
|
Email is compared case-insensitively and stripped of whitespace."""
|
||||||
|
db = get_db()
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM requests WHERE LOWER(TRIM(email)) = LOWER(TRIM(?)) ORDER BY created_at DESC",
|
||||||
|
(email,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def update_request(request_id, **fields):
|
||||||
|
"""
|
||||||
|
Update arbitrary columns for a request.
|
||||||
|
Example: update_request(1, status='prompt_ready', suno_style='...')
|
||||||
|
"""
|
||||||
|
if not fields:
|
||||||
|
return
|
||||||
|
db = get_db()
|
||||||
|
cols = ', '.join(f'{k} = ?' for k in fields)
|
||||||
|
vals = list(fields.values()) + [request_id]
|
||||||
|
db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_request(request_id):
|
||||||
|
"""Delete a single request row by id. Does NOT delete associated files
|
||||||
|
(the caller in app.py removes uploads before/after this call)."""
|
||||||
|
db = get_db()
|
||||||
|
db.execute('DELETE FROM requests WHERE id = ?', (request_id,))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def log_revision(request_id, revision_count, note, old_a=None, old_b=None, new_a=None, new_b=None):
|
||||||
|
"""Record a revision event in the revision_history table, creating it if it is missing."""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO revision_history
|
||||||
|
(request_id, revision_count, note, old_song_a_path, old_song_b_path, new_song_a_path, new_song_b_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(request_id, revision_count, note, old_a, old_b, new_a, new_b)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
if 'no such table' in str(e):
|
||||||
|
# Schema drift: table missing. Run init_db to add tables/columns, then retry once.
|
||||||
|
init_db()
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO revision_history
|
||||||
|
(request_id, revision_count, note, old_song_a_path, old_song_b_path, new_song_a_path, new_song_b_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(request_id, revision_count, note, old_a, old_b, new_a, new_b)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def list_revision_history(request_id):
|
||||||
|
"""Return all revision history rows for a request, oldest first."""
|
||||||
|
db = get_db()
|
||||||
|
rows = db.execute(
|
||||||
|
'SELECT * FROM revision_history WHERE request_id = ? ORDER BY created_at ASC',
|
||||||
|
(request_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def reset_all_requests():
|
||||||
|
"""Delete every row in the requests table and reset id auto-increment."""
|
||||||
|
db = get_db()
|
||||||
|
db.execute('DELETE FROM requests')
|
||||||
|
db.execute('DELETE FROM sqlite_sequence WHERE name = ?', ('requests',))
|
||||||
|
db.commit()
|
||||||
21
requirements.txt
Normal file
21
requirements.txt
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 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 secure filenames
|
||||||
|
# mutagen - MP3 metadata (ID3) tagging
|
||||||
|
# flask-limiter - public form rate limiting
|
||||||
|
# cryptography - encrypt stored SMTP password
|
||||||
|
|
||||||
|
flask
|
||||||
|
gunicorn
|
||||||
|
python-dotenv
|
||||||
|
werkzeug
|
||||||
|
mutagen
|
||||||
|
flask-limiter
|
||||||
|
cryptography
|
||||||
|
requests
|
||||||
BIN
static/Booth_closed.png
Executable file
BIN
static/Booth_closed.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 450 KiB |
BIN
static/DM-Logo_email.png
Executable file
BIN
static/DM-Logo_email.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
static/Trollgorithm_booth.jpg
Normal file
BIN
static/Trollgorithm_booth.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
static/qr-code.png
Executable file
BIN
static/qr-code.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
183
templates/admin/dashboard.html
Normal file
183
templates/admin/dashboard.html
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Dashboard</title>
|
||||||
|
{% if refresh_seconds and refresh_seconds > 0 %}
|
||||||
|
<meta http-equiv="refresh" content="{{ refresh_seconds }}">
|
||||||
|
{% endif %}
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Operator dashboard queue.
|
||||||
|
Shows all requests in a table with status filters, per-row Open/Delete
|
||||||
|
actions, a Settings link, auto-refresh hint, and logout.
|
||||||
|
*/
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.5;
|
||||||
|
}
|
||||||
|
.container{max-width:1100px;margin:0 auto;}
|
||||||
|
h1{color:#60a5fa;margin-top:0;}
|
||||||
|
.filters{margin-bottom:1rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;}
|
||||||
|
.filters a{
|
||||||
|
color:#93c5fd;
|
||||||
|
text-decoration:none;
|
||||||
|
padding:.35rem .7rem;
|
||||||
|
border-radius:.4rem;
|
||||||
|
border:1px solid transparent;
|
||||||
|
}
|
||||||
|
.filters a:hover{background:#1f2937;}
|
||||||
|
.filters a.active{
|
||||||
|
font-weight:bold;
|
||||||
|
color:#fff;
|
||||||
|
background:#2563eb;
|
||||||
|
border-color:#2563eb;
|
||||||
|
}
|
||||||
|
table{
|
||||||
|
width:100%;
|
||||||
|
border-collapse:collapse;
|
||||||
|
background:#1f2937;
|
||||||
|
border-radius:.5rem;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
th,td{padding:.7rem;text-align:left;border-bottom:1px solid #374151;}
|
||||||
|
th{background:#111827;color:#9ca3af;}
|
||||||
|
tr:hover{background:#2d3748;}
|
||||||
|
.status-badge{
|
||||||
|
display:inline-block;
|
||||||
|
padding:.25rem .6rem;
|
||||||
|
border-radius:9999px;
|
||||||
|
font-size:.8rem;
|
||||||
|
font-weight:600;
|
||||||
|
background:#374151;
|
||||||
|
}
|
||||||
|
.awaiting_payment{background:#f59e0b;color:#000;}
|
||||||
|
.paid,.delivered{background:#10b981;color:#000;}
|
||||||
|
.pending,.prompt_ready{background:#60a5fa;color:#000;}
|
||||||
|
.songs_uploaded{background:#a78bfa;color:#000;}
|
||||||
|
.revisions_requested{background:#f87171;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 Settings and Log out link */
|
||||||
|
.topbar{
|
||||||
|
float:right;
|
||||||
|
display:flex;
|
||||||
|
gap:.75rem;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
.topbar form{display:inline;}
|
||||||
|
.topbar a{
|
||||||
|
color:#93c5fd;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
.topbar a.settings{
|
||||||
|
color:#10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-hint{
|
||||||
|
color:#6b7280;
|
||||||
|
font-size:.85rem;
|
||||||
|
margin-left:auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Topbar: Kiosk, Pricing, Sales, Settings, Log out -->
|
||||||
|
<div class="topbar">
|
||||||
|
<a href="{{ url_for('kiosk') }}" target="_blank" rel="noopener noreferrer">Kiosk</a>
|
||||||
|
<a href="{{ url_for('admin_pricing') }}">Pricing</a>
|
||||||
|
<a href="{{ url_for('admin_sales') }}">Sales</a>
|
||||||
|
<a href="{{ url_for('admin_settings') }}" class="settings">Settings</a>
|
||||||
|
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Theme Song Booth — Admin Dashboard</h1>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Status filter links -->
|
||||||
|
<div class="filters">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="{% if current_status in (None, '') %}active{% endif %}">All</a>
|
||||||
|
<a href="{{ url_for('admin_dashboard', status='pending') }}" class="{% if current_status == 'pending' %}active{% endif %}">Pending</a>
|
||||||
|
<a href="{{ url_for('admin_dashboard', status='prompt_ready') }}" class="{% if current_status == 'prompt_ready' %}active{% endif %}">Needs Upload</a>
|
||||||
|
<a href="{{ url_for('admin_dashboard', status='awaiting_payment') }}" class="{% if current_status == 'awaiting_payment' %}active{% endif %}">Awaiting Payment</a>
|
||||||
|
<a href="{{ url_for('admin_dashboard', status='delivered') }}" class="{% if current_status == 'delivered' %}active{% endif %}">Delivered</a>
|
||||||
|
<span class="refresh-hint">
|
||||||
|
{% if refresh_seconds and refresh_seconds > 0 %}
|
||||||
|
Auto-refresh every {{ refresh_seconds }}s
|
||||||
|
{% else %}
|
||||||
|
Auto-refresh off
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Requests table -->
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Genre</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Approved</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in requests %}
|
||||||
|
<tr>
|
||||||
|
<td>#{{ r.id }}</td>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td>{{ r.email }}</td>
|
||||||
|
<td>{{ r.style_genre or '-' }}</td>
|
||||||
|
<td><span class="status-badge {{ r.status }}">{{ statuses[r.status] }}</span></td>
|
||||||
|
<td>{% if r.customer_approved and r.customer_approved != 'none' %}{{ (r.customer_approved or 'none').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>
|
||||||
|
</html>
|
||||||
70
templates/admin/login.html
Normal file
70
templates/admin/login.html
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Login</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Minimal login page for the operator dashboard.
|
||||||
|
Centered card with dark theme matching the rest of the app.
|
||||||
|
*/
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
display:flex;
|
||||||
|
justify-content:center;
|
||||||
|
align-items:center;
|
||||||
|
min-height:100vh;
|
||||||
|
}
|
||||||
|
form{
|
||||||
|
background:#1f2937;
|
||||||
|
padding:2rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
width:100%;
|
||||||
|
max-width:360px;
|
||||||
|
}
|
||||||
|
h1{margin-top:0;color:#60a5fa;}
|
||||||
|
label{display:block;margin-top:1rem;font-weight:600;}
|
||||||
|
input{
|
||||||
|
width:100%;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
button{
|
||||||
|
margin-top:1.5rem;
|
||||||
|
width:100%;
|
||||||
|
padding:.8rem;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background:#3b82f6;
|
||||||
|
color:#fff;
|
||||||
|
font-weight:700;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.flash{margin-top:1rem;color:#f87171;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form method="POST">
|
||||||
|
<h1>Booth Admin</h1>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autofocus>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash">{{ messages[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<button type="submit">Log In</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
108
templates/admin/pricing.html
Normal file
108
templates/admin/pricing.html
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pricing — Admin</title>
|
||||||
|
<style>
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.5;
|
||||||
|
}
|
||||||
|
.container{max-width:800px;margin:0 auto;}
|
||||||
|
h1{color:#60a5fa;margin-top:0;}
|
||||||
|
h2{color:#93c5fd;font-size:1.1rem;margin-top:1.5rem;}
|
||||||
|
a{color:#93c5fd;}
|
||||||
|
.topbar{float:right;display:flex;gap:.75rem;align-items:center;}
|
||||||
|
label{display:block;margin-top:.8rem;font-weight:600;font-size:.9rem;}
|
||||||
|
input[type="text"]{
|
||||||
|
width:100%;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
font:inherit;
|
||||||
|
}
|
||||||
|
.price-row{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:1fr 1fr;
|
||||||
|
gap:.75rem;
|
||||||
|
}
|
||||||
|
button{
|
||||||
|
margin-top:1rem;
|
||||||
|
padding:.7rem 1rem;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background:#3b82f6;
|
||||||
|
color:#fff;
|
||||||
|
font-weight:700;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
button.secondary{background:#4b5563;}
|
||||||
|
.flash{
|
||||||
|
padding:.8rem;
|
||||||
|
background:#064e3b;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.flash.error{background:#450a0a;}
|
||||||
|
.hint{font-size:.85rem;color:#9ca3af;margin-top:.25rem;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="topbar">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}">Dashboard</a>
|
||||||
|
<a href="{{ url_for('admin_sales') }}">Sales</a>
|
||||||
|
<a href="{{ url_for('admin_settings') }}">Settings</a>
|
||||||
|
<a href="{{ url_for('admin_logout') }}" style="color:#f87171">Log out</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Pricing</h1>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<h2>Standard Items</h2>
|
||||||
|
<p class="hint">Leave blank to hide a price. Enter numbers only or include currency symbols as you prefer.</p>
|
||||||
|
|
||||||
|
<label for="one_song">One Song</label>
|
||||||
|
<input type="text" id="one_song" name="one_song" value="{{ fixed.one_song }}" placeholder="e.g. 25.00">
|
||||||
|
|
||||||
|
<label for="both_songs">Both Songs</label>
|
||||||
|
<input type="text" id="both_songs" name="both_songs" value="{{ fixed.both_songs }}" placeholder="e.g. 40.00">
|
||||||
|
|
||||||
|
<label for="wav_per_song">WAV files / song</label>
|
||||||
|
<input type="text" id="wav_per_song" name="wav_per_song" value="{{ fixed.wav_per_song }}" placeholder="e.g. 10.00">
|
||||||
|
|
||||||
|
<label for="stems_per_song">STEM files / song</label>
|
||||||
|
<input type="text" id="stems_per_song" name="stems_per_song" value="{{ fixed.stems_per_song }}" placeholder="e.g. 25.00">
|
||||||
|
|
||||||
|
<h2>Custom Items</h2>
|
||||||
|
{% for c in customs %}
|
||||||
|
<div class="price-row">
|
||||||
|
<div>
|
||||||
|
<label for="custom_name_{{ loop.index }}">Item {{ loop.index }} name</label>
|
||||||
|
<input type="text" id="custom_name_{{ loop.index }}" name="custom_name_{{ loop.index }}" value="{{ c.name }}" placeholder="e.g. Rush delivery">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="custom_price_{{ loop.index }}">Item {{ loop.index }} price</label>
|
||||||
|
<input type="text" id="custom_price_{{ loop.index }}" name="custom_price_{{ loop.index }}" value="{{ c.price }}" placeholder="e.g. 15.00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<button type="submit">Save Pricing</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
550
templates/admin/request.html
Normal file
550
templates/admin/request.html
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Request #{{ req.id }} — Admin</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Operator detail page for a single request.
|
||||||
|
Two-column layout:
|
||||||
|
left -> customer info + internal operator notes
|
||||||
|
right -> Suno prompt, song upload, customer notification,
|
||||||
|
payment reference, and file delivery.
|
||||||
|
*/
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
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:1200px;margin:0 auto;}
|
||||||
|
h1,h2{color:#60a5fa;margin-top:0;}
|
||||||
|
a{color:#93c5fd;}
|
||||||
|
.section{
|
||||||
|
background:#1f2937;
|
||||||
|
padding:1rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
label{display:block;margin-top:.8rem;font-weight:600;}
|
||||||
|
input,textarea{
|
||||||
|
width:100%;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
box-sizing:border-box;
|
||||||
|
font:inherit;
|
||||||
|
}
|
||||||
|
textarea{min-height:100px;}
|
||||||
|
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;}
|
||||||
|
button:disabled{background:#374151;color:#9ca3af;cursor:not-allowed;}
|
||||||
|
.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;}
|
||||||
|
.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;}
|
||||||
|
.revision-note{
|
||||||
|
margin-top:1rem;
|
||||||
|
background:#450a0a;
|
||||||
|
border:1px solid #7f1d1d;
|
||||||
|
border-radius:.5rem;
|
||||||
|
padding:1rem;
|
||||||
|
}
|
||||||
|
.revision-note h3{
|
||||||
|
margin-top:0;
|
||||||
|
color:#f87171;
|
||||||
|
}
|
||||||
|
.file-select{
|
||||||
|
background:#111827;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin:.3rem 0;
|
||||||
|
}
|
||||||
|
.file-select input{
|
||||||
|
width:auto;
|
||||||
|
margin-right:.5rem;
|
||||||
|
}
|
||||||
|
.file-select label{
|
||||||
|
display:inline;
|
||||||
|
margin:0;
|
||||||
|
font-weight:400;
|
||||||
|
}
|
||||||
|
.song-row{
|
||||||
|
display:flex;
|
||||||
|
align-items:flex-start;
|
||||||
|
gap:.75rem;
|
||||||
|
margin:.6rem 0;
|
||||||
|
padding:.4rem 0;
|
||||||
|
}
|
||||||
|
.song-row input[type="checkbox"]{
|
||||||
|
width:auto;
|
||||||
|
margin:.2rem 0 0 0;
|
||||||
|
flex-shrink:0;
|
||||||
|
}
|
||||||
|
.song-row label{
|
||||||
|
display:block;
|
||||||
|
margin:0;
|
||||||
|
font-weight:400;
|
||||||
|
line-height:1.4;
|
||||||
|
}
|
||||||
|
.old-rev{
|
||||||
|
font-size:.85rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
margin-left:1.8rem;
|
||||||
|
}
|
||||||
|
.approved-box{font-size:1.2rem;font-weight:bold;color:#fbbf24;}
|
||||||
|
.status-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;}
|
||||||
|
.history-list{
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
list-style:none;
|
||||||
|
}
|
||||||
|
.history-list li{
|
||||||
|
border-bottom:1px solid #374151;
|
||||||
|
padding:.6rem 0;
|
||||||
|
}
|
||||||
|
.history-list li:last-child{border-bottom:none;}
|
||||||
|
.history-list .meta{
|
||||||
|
font-size:.8rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
}
|
||||||
|
.history-list .note{
|
||||||
|
margin:.3rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-column layout */
|
||||||
|
.two-col{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:1fr 1fr;
|
||||||
|
gap:1rem;
|
||||||
|
}
|
||||||
|
@media(max-width:900px){
|
||||||
|
.two-col{grid-template-columns:1fr;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p><a href="{{ url_for('admin_dashboard') }}">← Dashboard</a></p>
|
||||||
|
<h1>Request #{{ req.id }} — {{ req.name }}</h1>
|
||||||
|
<div class="status-row">
|
||||||
|
<p style="margin:0"><strong>Status:</strong> <span class="status-badge">{{ statuses[req.status] }}</span></p>
|
||||||
|
{% if req.status != 'cancelled' %}
|
||||||
|
<form method="POST" onsubmit="return confirm('Are you sure you want to cancel request #{{ req.id }}?')" style="margin:0">
|
||||||
|
<input type="hidden" name="action" value="cancel_request">
|
||||||
|
<button type="submit" class="danger" style="margin:0">Cancel Request</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<!-- LEFT COLUMN -->
|
||||||
|
<div class="left">
|
||||||
|
<!-- Customer Info -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Customer Info</h2>
|
||||||
|
<p class="copy-hint">Operators can correct customer details here. Click Save when done.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="update_customer_info">
|
||||||
|
|
||||||
|
<label for="customer_email">Email address</label>
|
||||||
|
<input type="email" id="customer_email" name="email" value="{{ req.email }}" required>
|
||||||
|
|
||||||
|
<label for="customer_name">Name / persona</label>
|
||||||
|
<input type="text" id="customer_name" name="name" value="{{ req.name }}" required>
|
||||||
|
|
||||||
|
<label for="customer_pronouns">Pronouns <span style="color:#f87171">*</span></label>
|
||||||
|
<select id="customer_pronouns" name="pronouns" required>
|
||||||
|
<option value="" {% if not req.pronouns %}selected{% endif %}>— Select pronouns —</option>
|
||||||
|
<option value="He/Him/His" {% if req.pronouns == 'He/Him/His' %}selected{% endif %}>He/Him/His</option>
|
||||||
|
<option value="She/Her/Hers" {% if req.pronouns == 'She/Her/Hers' %}selected{% endif %}>She/Her/Hers</option>
|
||||||
|
<option value="They/Them/Their" {% if req.pronouns == 'They/Them/Their' %}selected{% endif %}>They/Them/Their</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="customer_hobbies">Hobbies & interests</label>
|
||||||
|
<textarea id="customer_hobbies" name="hobbies" rows="3">{{ req.hobbies or '' }}</textarea>
|
||||||
|
|
||||||
|
<label for="customer_notable_facts">Notable things</label>
|
||||||
|
<textarea id="customer_notable_facts" name="notable_facts" rows="3">{{ req.notable_facts or '' }}</textarea>
|
||||||
|
|
||||||
|
<label for="customer_style_genre_decade">Decade / era <span style="color:#f87171">*</span></label>
|
||||||
|
<select id="customer_style_genre_decade" name="decade" required>
|
||||||
|
<option value="" {% if not style_parts.decade %}selected{% endif %}>— Select decade —</option>
|
||||||
|
{% for d in decades %}
|
||||||
|
<option value="{{ d }}" {% if style_parts.decade == d %}selected{% endif %}>{{ d }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="customer_style_genre_basic">Basic style <span style="color:#f87171">*</span></label>
|
||||||
|
<select id="customer_style_genre_basic" name="basic_style" required>
|
||||||
|
<option value="" {% if not style_parts.basic_style %}selected{% endif %}>— Select style —</option>
|
||||||
|
{% for g in genres %}
|
||||||
|
<option value="{{ g }}" {% if style_parts.basic_style == g %}selected{% endif %}>{{ g }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="customer_style_genre_additional">Additional style (optional)</label>
|
||||||
|
<select id="customer_style_genre_additional" name="additional_style">
|
||||||
|
<option value="" {% if not style_parts.additional_style %}selected{% endif %}>— None —</option>
|
||||||
|
{% for g in genres %}
|
||||||
|
<option value="{{ g }}" {% if style_parts.additional_style == g %}selected{% endif %}>{{ g }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<p style="font-size:.85rem;color:#9ca3af;margin-top:.3rem">Stored style: {{ req.style_genre or '—' }}</p>
|
||||||
|
|
||||||
|
<label for="customer_vocal_gender">Preferred singer voice / gender</label>
|
||||||
|
<select id="customer_vocal_gender" name="vocal_gender">
|
||||||
|
<option value="" {% if not req.vocal_gender %}selected{% endif %}>No preference</option>
|
||||||
|
<option value="female" {% if req.vocal_gender == 'female' %}selected{% endif %}>Female</option>
|
||||||
|
<option value="male" {% if req.vocal_gender == 'male' %}selected{% endif %}>Male</option>
|
||||||
|
<option value="non-binary" {% if req.vocal_gender == 'non-binary' %}selected{% endif %}>Non-binary / Gender-neutral</option>
|
||||||
|
<option value="androgynous" {% if req.vocal_gender == 'androgynous' %}selected{% endif %}>Androgynous</option>
|
||||||
|
<option value="other" {% if req.vocal_gender == 'other' %}selected{% endif %}>Other (mention in Extra Requests)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="customer_extra_requests">Anything else</label>
|
||||||
|
<textarea id="customer_extra_requests" name="extra_requests" rows="3" maxlength="2000">{{ req.extra_requests or '' }}</textarea>
|
||||||
|
|
||||||
|
<label for="customer_stems_interest" style="display:flex;align-items:center;gap:.5rem;margin-top:1rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="customer_stems_interest" name="stems_interest" value="1" {% if req.stems_interest %}checked{% endif %} style="width:auto;margin:0">
|
||||||
|
Customer is interested in STEMS / multitrack files
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="secondary">Save Customer Info</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="margin-top:1rem"><strong>Request #:</strong> {{ req.id }}</p>
|
||||||
|
|
||||||
|
{% if req.revision_note %}
|
||||||
|
<div class="revision-note">
|
||||||
|
<h3>📝 Revisions Requested{% if req.revision_count %}<span style="float:right">Revision #{{ req.revision_count }}</span>{% endif %}</h3>
|
||||||
|
<p>{{ req.revision_note }}</p>
|
||||||
|
<button type="button" onclick="copyRevisionPromptForHermes()">📋 Copy revision prompt for Hermes</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revision History -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Revision History</h2>
|
||||||
|
{% if revision_history %}
|
||||||
|
<ul class="history-list">
|
||||||
|
{% for h in revision_history %}
|
||||||
|
<li>
|
||||||
|
<div class="meta">Revision #{{ h.revision_count }} — {{ h.created_at }}
|
||||||
|
{% if h.old_song_a_path or h.old_song_b_path %}
|
||||||
|
<br>Archived:
|
||||||
|
{% if h.old_song_a_path %}<br>A: {{ basename(h.old_song_a_path) }}{% endif %}
|
||||||
|
{% if h.old_song_b_path %}<br>B: {{ basename(h.old_song_b_path) }}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="note">{{ h.note or 'No note provided.' }}</p>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="copy-hint">No revisions recorded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Operator Notes -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Operator Notes</h2>
|
||||||
|
<p class="copy-hint">Internal notes for the booth team. Customers cannot see this.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_operator_notes">
|
||||||
|
<label for="operator_notes">Notes</label>
|
||||||
|
<textarea id="operator_notes" name="operator_notes" rows="4" placeholder="e.g. paid cash, VIP friend, follow up...">{{ req.operator_notes or '' }}</textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="secondary">Save Notes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stems / Extras Link -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Stems / Extras Link</h2>
|
||||||
|
<p class="copy-hint">Paste a self-hosted file share link (e.g. from Pingvin Share). It will appear on the customer player page once delivered.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_stems_link">
|
||||||
|
<label for="stems_link">Share link</label>
|
||||||
|
<input type="url" id="stems_link" name="stems_link" value="{{ req.stems_link or '' }}" placeholder="https://files.dionysismedia.ca/share/...">
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="secondary">Save Link</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN -->
|
||||||
|
<div class="right">
|
||||||
|
<!-- Generate Suno Prompt -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>1. Generate & Save Suno Prompt</h2>
|
||||||
|
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
|
||||||
|
<p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save.</p>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_prompt">
|
||||||
|
<label for="suno_title">Title</label>
|
||||||
|
<input type="text" id="suno_title" name="suno_title" value="{{ req.suno_title or '' }}">
|
||||||
|
<label for="suno_style">Style</label>
|
||||||
|
<textarea id="suno_style" name="suno_style">{{ req.suno_style or '' }}</textarea>
|
||||||
|
<label for="suno_lyrics">Lyrics (with metatags)</label>
|
||||||
|
<textarea id="suno_lyrics" name="suno_lyrics" rows="10">{{ req.suno_lyrics or '' }}</textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="secondary" onclick="copyToClipboard('suno_lyrics', 'Lyrics')">Copy Lyrics</button>
|
||||||
|
<button type="button" class="secondary" onclick="copyToClipboard('suno_style', 'Style')">Copy Style</button>
|
||||||
|
<button type="button" class="secondary" onclick="copyToClipboard('suno_title', 'Title')">Copy Title</button>
|
||||||
|
<button type="submit">Save Prompt</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Songs -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>2. Upload Songs</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="delete_songs">
|
||||||
|
<div class="song-row">
|
||||||
|
<input type="checkbox" id="delete_a" name="delete_song" value="a"
|
||||||
|
{% if not (req.song_a_path and file_exists(req.song_a_path)) %}disabled{% endif %}>
|
||||||
|
<label for="delete_a">Version A:
|
||||||
|
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
||||||
|
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_a_path) }}
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge missing">❌ Not uploaded</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="song-row">
|
||||||
|
<input type="checkbox" id="delete_b" name="delete_song" value="b"
|
||||||
|
{% if not (req.song_b_path and file_exists(req.song_b_path)) %}disabled{% endif %}>
|
||||||
|
<label for="delete_b">Version B:
|
||||||
|
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
||||||
|
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_b_path) }}
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge missing">❌ Not uploaded</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="danger" onclick="return confirm('Delete selected song files? This will reset the request to Prompt Ready.')">Delete Selected Songs</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr style="border-color:#374151;margin:1rem 0">
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="action" value="upload_songs">
|
||||||
|
<label for="song_a">Version A MP3</label>
|
||||||
|
<input type="file" id="song_a" name="song_a" accept="audio/mpeg,.mp3">
|
||||||
|
<label for="song_b">Version B MP3</label>
|
||||||
|
<input type="file" id="song_b" name="song_b" accept="audio/mpeg,.mp3">
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Upload Songs</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notify Customer -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>3. Notify Customer</h2>
|
||||||
|
<p>
|
||||||
|
Preview email:
|
||||||
|
{% if req.preview_sent_at %}
|
||||||
|
<span class="status-badge sent">✅ Sent</span> {{ req.preview_sent_at }}
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge missing">❌ Not sent yet</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p>Both songs must be uploaded first.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="notify_customer">
|
||||||
|
<button type="submit" {% if not (req.song_a_path and req.song_b_path) %}disabled{% endif %}>Send Preview Link</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if req.song_a_path and req.song_b_path %}
|
||||||
|
<p style="margin-top:.8rem">
|
||||||
|
Operator preview link:
|
||||||
|
<a href="{{ url_for('play', token=req.player_token) }}" target="_blank" rel="noopener noreferrer">Open customer player in new tab →</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment & Delivery -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>4. Payment & Delivery</h2>
|
||||||
|
<p>
|
||||||
|
Customer approved:
|
||||||
|
{% if req.customer_approved == 'none' %}
|
||||||
|
<span class="approved-box">Nothing yet</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="approved-box">{{ (req.customer_approved or 'none').upper() }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</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>
|
||||||
|
{% if req.square_payment_ref %}
|
||||||
|
<p><strong>Square Payment Reference:</strong> {{ req.square_payment_ref }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST">
|
||||||
|
<!-- Shared payment reference field -->
|
||||||
|
<label for="square_payment_ref">Square Payment Reference</label>
|
||||||
|
<input type="text" id="square_payment_ref" name="square_payment_ref" value="{{ req.square_payment_ref or '' }}" placeholder="e.g. sq0idp-... or receipt number">
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" name="action" value="update_payment_ref" class="secondary">Update Payment Reference</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border-color:#374151;margin:1rem 0">
|
||||||
|
|
||||||
|
<p style="color:#93c5fd;font-weight:600">Select files to deliver:</p>
|
||||||
|
{% set all_files = [] %}
|
||||||
|
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
||||||
|
{% set _ = all_files.append(req.song_a_path) %}
|
||||||
|
{% endif %}
|
||||||
|
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
||||||
|
{% set _ = all_files.append(req.song_b_path) %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set files_to_show = all_files + extra_files %}
|
||||||
|
{% for fpath in files_to_show %}
|
||||||
|
<div class="file-select">
|
||||||
|
<input type="checkbox" id="file_{{ loop.index }}" name="deliver_file" value="{{ fpath }}"
|
||||||
|
{% if fpath in all_files %}checked{% endif %}
|
||||||
|
{% if req.customer_approved == 'none' %}disabled{% endif %}>
|
||||||
|
<label for="file_{{ loop.index }}"{% if req.customer_approved == 'none' %} style="opacity:.6"{% endif %}>{{ basename(fpath) }}</label>
|
||||||
|
{% if not (fpath == req.song_a_path or fpath == req.song_b_path) %}
|
||||||
|
<div class="old-rev">archived / revision file</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No files available. Upload songs first.</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" name="action" value="mark_paid_deliver" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid & Deliver</button>
|
||||||
|
</div>
|
||||||
|
{% if req.customer_approved == 'none' %}
|
||||||
|
<p style="margin-top:.5rem;color:#f87171;font-weight:600">⚠️ Customer must approve a version before you can mark paid or deliver.</p>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyPromptForHermes() {
|
||||||
|
const callbackUrl = {{ callback_url | tojson }};
|
||||||
|
const styleGenre = {{ req.style_genre | tojson }};
|
||||||
|
const styleParts = styleGenre ? styleGenre.split(', ') : [];
|
||||||
|
const decade = styleParts[0] || '';
|
||||||
|
const basic = styleParts[1] || '';
|
||||||
|
const additional = styleParts.slice(2).join(', ') || '';
|
||||||
|
const styleSentence = [decade && `${decade}-era`, basic, additional && `with ${additional} influences`].filter(Boolean).join(' ');
|
||||||
|
const data = {
|
||||||
|
request_id: {{ req.id | tojson }},
|
||||||
|
email: {{ req.email | tojson }},
|
||||||
|
name: {{ req.name | tojson }},
|
||||||
|
pronouns: {{ req.pronouns | tojson }},
|
||||||
|
hobbies: {{ req.hobbies | tojson }},
|
||||||
|
notable_facts: {{ req.notable_facts | tojson }},
|
||||||
|
style_genre: styleGenre,
|
||||||
|
vocal_gender: {{ req.vocal_gender | tojson }},
|
||||||
|
extra_requests: {{ req.extra_requests | tojson }},
|
||||||
|
stems_interest: {{ (req.stems_interest or 0) | tojson }}
|
||||||
|
};
|
||||||
|
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
|
||||||
|
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes. Hermes will POST the generated prompt back to the Callback URL."));
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(elementId, label) {
|
||||||
|
const el = document.getElementById(elementId);
|
||||||
|
el.select();
|
||||||
|
el.setSelectionRange(0, 99999);
|
||||||
|
navigator.clipboard.writeText(el.value).then(() => alert(label + " copied!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRevisionPromptForHermes() {
|
||||||
|
const callbackUrl = {{ callback_url | tojson }};
|
||||||
|
const currentTitle = {{ req.suno_title | tojson }};
|
||||||
|
const currentStyle = {{ req.suno_style | tojson }};
|
||||||
|
const currentLyrics = {{ req.suno_lyrics | tojson }};
|
||||||
|
const revisionNote = {{ req.revision_note | tojson }};
|
||||||
|
const revisionCount = {{ req.revision_count | tojson }};
|
||||||
|
const styleGenre = {{ req.style_genre | tojson }};
|
||||||
|
const styleParts = styleGenre ? styleGenre.split(', ') : [];
|
||||||
|
const decade = styleParts[0] || '';
|
||||||
|
const basic = styleParts[1] || '';
|
||||||
|
const additional = styleParts.slice(2).join(', ') || '';
|
||||||
|
const styleSentence = [decade && `${decade}-era`, basic, additional && `with ${additional} influences`].filter(Boolean).join(' ');
|
||||||
|
const data = {
|
||||||
|
request_id: {{ req.id | tojson }},
|
||||||
|
email: {{ req.email | tojson }},
|
||||||
|
name: {{ req.name | tojson }},
|
||||||
|
pronouns: {{ req.pronouns | tojson }},
|
||||||
|
hobbies: {{ req.hobbies | tojson }},
|
||||||
|
notable_facts: {{ req.notable_facts | tojson }},
|
||||||
|
style_genre: styleGenre,
|
||||||
|
vocal_gender: {{ req.vocal_gender | tojson }},
|
||||||
|
extra_requests: {{ req.extra_requests | tojson }},
|
||||||
|
stems_interest: {{ (req.stems_interest or 0) | tojson }}
|
||||||
|
};
|
||||||
|
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW Suno Custom Mode prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nPreviously generated prompt (do not copy verbatim; adapt and improve):\\nTitle: ${currentTitle || '-'}\\nStyle: ${currentStyle || '-'}\\nLyrics:\\n${currentLyrics || '-'}\\n\\nRevision request from customer:\\n${revisionNote || '-'}\\n\\nPOST the new prompt back to the Callback URL as JSON with this shape:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
|
||||||
|
navigator.clipboard.writeText(text).then(() => alert("Revision prompt copied! Paste into Hermes. Hermes will POST the revised prompt back to the Callback URL."));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
templates/admin/sales.html
Normal file
72
templates/admin/sales.html
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sales Report — Admin</title>
|
||||||
|
<style>
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.5;
|
||||||
|
}
|
||||||
|
.container{max-width:1100px;margin:0 auto;}
|
||||||
|
h1{color:#60a5fa;margin-top:0;}
|
||||||
|
a{color:#93c5fd;}
|
||||||
|
table{
|
||||||
|
width:100%;
|
||||||
|
border-collapse:collapse;
|
||||||
|
background:#1f2937;
|
||||||
|
border-radius:.5rem;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
th,td{padding:.7rem;text-align:left;border-bottom:1px solid #374151;}
|
||||||
|
th{background:#111827;color:#9ca3af;}
|
||||||
|
tr:hover{background:#2d3748;}
|
||||||
|
.topbar{float:right;display:flex;gap:.75rem;align-items:center;}
|
||||||
|
.topbar a{color:#93c5fd;text-decoration:none;}
|
||||||
|
.empty{padding:1rem;color:#9ca3af;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="topbar">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}">Dashboard</a>
|
||||||
|
<a href="{{ url_for('admin_settings') }}">Settings</a>
|
||||||
|
<a href="{{ url_for('admin_logout') }}" style="color:#f87171">Log out</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Sales Report</h1>
|
||||||
|
|
||||||
|
{% if sales %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Picked</th>
|
||||||
|
<th>Payment Reference</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sales %}
|
||||||
|
<tr>
|
||||||
|
<td>#{{ s.id }}</td>
|
||||||
|
<td>{{ s.name }}</td>
|
||||||
|
<td>{{ s.email }}</td>
|
||||||
|
<td>{% if s.customer_approved == 'both' %}Both Versions{% else %}Version {{ (s.customer_approved or 'none').upper() }}{% endif %}</td>
|
||||||
|
<td>{{ s.square_payment_ref or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">No delivered requests yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
470
templates/admin/settings.html
Normal file
470
templates/admin/settings.html
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Settings</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Admin settings / maintenance page.
|
||||||
|
Two-column layout for booth open/closed status, database health,
|
||||||
|
statistics, revision limit, auto-refresh, SMTP config,
|
||||||
|
MP3 metadata defaults, database backup/restore, and system reset.
|
||||||
|
*/
|
||||||
|
:root{
|
||||||
|
--bg:#0b0f19;
|
||||||
|
--panel:#111827;
|
||||||
|
--text:#f3f4f6;
|
||||||
|
--muted:#9ca3af;
|
||||||
|
--accent:#60a5fa;
|
||||||
|
--accent-dark:#2563eb;
|
||||||
|
--danger:#dc2626;
|
||||||
|
--danger-dark:#991b1b;
|
||||||
|
--success:#10b981;
|
||||||
|
--warning:#f59e0b;
|
||||||
|
--border:#374151;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
body{
|
||||||
|
margin:0;
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:var(--bg);
|
||||||
|
color:var(--text);
|
||||||
|
line-height:1.5;
|
||||||
|
}
|
||||||
|
.container{max-width:1200px;margin:0 auto;padding:1.5rem;}
|
||||||
|
.topbar{
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
align-items:center;
|
||||||
|
margin-bottom:1.5rem;
|
||||||
|
}
|
||||||
|
h1{margin:0;font-size:1.5rem;}
|
||||||
|
.nav a{
|
||||||
|
color:var(--accent);
|
||||||
|
text-decoration:none;
|
||||||
|
margin-left:1rem;
|
||||||
|
}
|
||||||
|
.messages p{
|
||||||
|
padding:.75rem 1rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin:0 0 1rem;
|
||||||
|
}
|
||||||
|
.messages .success{background:#064e3b;color:#a7f3d0;}
|
||||||
|
.messages .error{background:#450a0a;color:#fca5a5;}
|
||||||
|
|
||||||
|
/* Two-column grid for settings */
|
||||||
|
.grid{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:repeat(2, 1fr);
|
||||||
|
gap:1.5rem;
|
||||||
|
}
|
||||||
|
@media (max-width:900px){
|
||||||
|
.grid{grid-template-columns:1fr;}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section{
|
||||||
|
background:var(--panel);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:.75rem;
|
||||||
|
padding:1.25rem;
|
||||||
|
}
|
||||||
|
.section h2{
|
||||||
|
margin-top:0;
|
||||||
|
font-size:1.15rem;
|
||||||
|
color:var(--accent);
|
||||||
|
}
|
||||||
|
.section p{
|
||||||
|
margin:.5rem 0;
|
||||||
|
color:var(--muted);
|
||||||
|
font-size:.95rem;
|
||||||
|
}
|
||||||
|
label{
|
||||||
|
display:block;
|
||||||
|
margin-top:.75rem;
|
||||||
|
font-size:.9rem;
|
||||||
|
color:var(--muted);
|
||||||
|
}
|
||||||
|
input[type="text"],input[type="number"],input[type="password"],textarea,select{
|
||||||
|
width:100%;
|
||||||
|
padding:.6rem;
|
||||||
|
border-radius:.4rem;
|
||||||
|
border:1px solid var(--border);
|
||||||
|
background:#1f2937;
|
||||||
|
color:var(--text);
|
||||||
|
font:inherit;
|
||||||
|
margin-top:.25rem;
|
||||||
|
}
|
||||||
|
textarea{resize:vertical;}
|
||||||
|
button{
|
||||||
|
margin-top:1rem;
|
||||||
|
padding:.65rem 1.2rem;
|
||||||
|
background:var(--accent-dark);
|
||||||
|
color:#fff;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
cursor:pointer;
|
||||||
|
font-weight:600;
|
||||||
|
}
|
||||||
|
button:hover{background:var(--accent);}
|
||||||
|
|
||||||
|
/* Metadata and email config tables */
|
||||||
|
.meta-table{
|
||||||
|
width:100%;
|
||||||
|
border-collapse:collapse;
|
||||||
|
margin-top:.5rem;
|
||||||
|
}
|
||||||
|
.meta-table td{
|
||||||
|
padding:.5rem;
|
||||||
|
vertical-align:top;
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.meta-table td:first-child{
|
||||||
|
width:30%;
|
||||||
|
font-size:.9rem;
|
||||||
|
color:var(--muted);
|
||||||
|
padding-left:0;
|
||||||
|
}
|
||||||
|
.meta-table td:last-child{padding-right:0;}
|
||||||
|
.meta-table tr:last-child td{border-bottom:none;}
|
||||||
|
.meta-table input,.meta-table textarea,.meta-table select{
|
||||||
|
margin-top:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone{
|
||||||
|
grid-column:1 / -1;
|
||||||
|
background:#450a0a;
|
||||||
|
border:1px solid #7f1d1d;
|
||||||
|
border-radius:.75rem;
|
||||||
|
padding:1.25rem;
|
||||||
|
}
|
||||||
|
.danger-zone h2{margin-top:0;color:#fca5a5;}
|
||||||
|
.danger-zone button{
|
||||||
|
background:var(--danger);
|
||||||
|
}
|
||||||
|
.danger-zone button:hover{background:var(--danger-dark);}
|
||||||
|
|
||||||
|
.status-ok{color:var(--success);font-weight:600;}
|
||||||
|
.status-bad{color:var(--warning);font-weight:600;}
|
||||||
|
.copy-hint{font-size:.85rem;color:var(--muted);margin-top:.25rem;}
|
||||||
|
.path{word-break:break-all;font-family:monospace;font-size:.85rem;}
|
||||||
|
.version-tag{
|
||||||
|
display:inline-block;
|
||||||
|
background:#2563eb;
|
||||||
|
color:#fff;
|
||||||
|
font-size:.85rem;
|
||||||
|
font-weight:700;
|
||||||
|
padding:.25rem .6rem;
|
||||||
|
border-radius:.4rem;
|
||||||
|
margin-left:.75rem;
|
||||||
|
vertical-align:middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="topbar">
|
||||||
|
<h1>Admin Settings <span class="version-tag">v{{ version }}</span></h1>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}">← Back to Dashboard</a>
|
||||||
|
<a href="{{ url_for('admin_logout') }}">Log out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<p class="{{ category }}">{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<!-- Database health check -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Booth Status</h2>
|
||||||
|
<p class="copy-hint">When closed, the public request page shows a "booth closed" message instead of the form.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_booth_open">
|
||||||
|
<label for="booth_open">Booth is currently</label>
|
||||||
|
<select id="booth_open" name="booth_open">
|
||||||
|
<option value="1" {% if booth_open %}selected{% endif %}>Open ✅</option>
|
||||||
|
<option value="0" {% if not booth_open %}selected{% endif %}>Closed ❌</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Save Booth Status</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Database health check -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Database Health</h2>
|
||||||
|
{% if not health.ok %}
|
||||||
|
<p class="status-bad">❌ {{ health.message }}</p>
|
||||||
|
{% if health.missing_columns %}
|
||||||
|
<p>Missing columns: {{ health.missing_columns | join(', ') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if health.missing_tables %}
|
||||||
|
<p>Missing tables: {{ health.missing_tables | join(', ') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ url_for('admin_settings') }}">
|
||||||
|
<input type="hidden" name="action" value="fix_db">
|
||||||
|
<button type="submit">Fix Database Schema</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="status-ok">✅ {{ health.message }}</p>
|
||||||
|
<form method="POST" action="{{ url_for('admin_settings') }}" style="margin-top:.5rem">
|
||||||
|
<input type="hidden" name="action" value="fix_db">
|
||||||
|
<button type="submit" class="secondary">Recheck / Apply Schema</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Database statistics -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Database Statistics</h2>
|
||||||
|
<p><strong>Total requests:</strong> {{ total_records }}</p>
|
||||||
|
<p><strong>Database size:</strong> {{ db_size }}</p>
|
||||||
|
<p><strong>Uploaded MP3s:</strong> {{ upload_file_count }} files in {{ upload_dir_count }} request folders</p>
|
||||||
|
<p><strong>Uploads total size:</strong> {{ upload_size }}</p>
|
||||||
|
{% if status_counts %}
|
||||||
|
<p><strong>Status breakdown:</strong></p>
|
||||||
|
<ul>
|
||||||
|
{% for status, count in status_counts.items() %}
|
||||||
|
<li>{{ status }}: {{ count }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<p><strong>Database path:</strong><br><span class="path">{{ db_path }}</span></p>
|
||||||
|
<p><strong>Uploads path:</strong><br><span class="path">{{ upload_path }}</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revision limit -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Revision Limit</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_max_revisions">
|
||||||
|
<label for="max_revisions">Maximum customer revisions allowed</label>
|
||||||
|
<input type="number" id="max_revisions" name="max_revisions" min="0" value="{{ current_max_revisions }}">
|
||||||
|
<p class="copy-hint">Set to 0 to disable customer-submitted revisions. Operators can still upload new versions manually.</p>
|
||||||
|
<button type="submit">Save Revision Limit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto refresh -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Dashboard Auto-Refresh</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_refresh">
|
||||||
|
<label for="refresh_seconds">Refresh interval</label>
|
||||||
|
<select id="refresh_seconds" name="refresh_seconds">
|
||||||
|
<option value="0" {% if current_refresh_seconds == 0 %}selected{% endif %}>Off</option>
|
||||||
|
<option value="10" {% if current_refresh_seconds == 10 %}selected{% endif %}>10 seconds</option>
|
||||||
|
<option value="20" {% if current_refresh_seconds == 20 %}selected{% endif %}>20 seconds</option>
|
||||||
|
<option value="30" {% if current_refresh_seconds == 30 %}selected{% endif %}>30 seconds</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Save Refresh Interval</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kiosk cycle -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Kiosk Display Mode</h2>
|
||||||
|
<p class="copy-hint">Set how the public <a href="{{ url_for('kiosk') }}" target="_blank" rel="noopener noreferrer">/kiosk</a> page cycles. Use -1 for QR only, 0 for pricing only, 1 for queue only, or 5+ seconds to cycle between QR, pricing, and queue.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_kiosk_cycle">
|
||||||
|
<label for="kiosk_cycle_seconds">Kiosk cycle seconds</label>
|
||||||
|
<input type="number" id="kiosk_cycle_seconds" name="kiosk_cycle_seconds" value="{{ current_kiosk_cycle_seconds }}" min="-1">
|
||||||
|
<p class="copy-hint">Examples: -1 = QR only, 0 = pricing only, 1 = queue only, 10 = rotate QR → pricing → queue every 10 seconds.</p>
|
||||||
|
<button type="submit">Save Kiosk Mode</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hermes API Key display (env var only; no regeneration) -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Hermes API Key</h2>
|
||||||
|
<p class="copy-hint">Set via Portainer env var <code>HERMES_API_KEY</code>. Used for callback auth.</p>
|
||||||
|
<p><strong>Current key:</strong> <code>{{ hermes_key_masked }}</code></p>
|
||||||
|
|
||||||
|
{% if not hermes_key_set %}
|
||||||
|
<p class="status-bad">⚠️ No Hermes API key is configured. Set the HERMES_API_KEY environment variable in Portainer before using the callback workflow.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0;">
|
||||||
|
|
||||||
|
<h3>Callback Link Expiry</h3>
|
||||||
|
<p class="copy-hint">How long the signed Hermes callback URL stays valid (in hours). Default is 168 hours (7 days).</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_callback_expiry">
|
||||||
|
<label for="callback_expiry_hours">Callback expiry (hours)</label>
|
||||||
|
<input type="number" id="callback_expiry_hours" name="callback_expiry_hours" value="{{ current_callback_expiry_hours }}" min="1">
|
||||||
|
<button type="submit">Save Callback Expiry</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ntfy push notifications -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>ntfy Notifications</h2>
|
||||||
|
<p class="copy-hint">Push notifications for new customer requests. Leave either field blank to disable.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_ntfy">
|
||||||
|
<table class="meta-table">
|
||||||
|
<tr>
|
||||||
|
<td>ntfy Server URL</td>
|
||||||
|
<td><input type="text" id="ntfy_server" name="ntfy_server" value="{{ ntfy.get('ntfy_server', '') }}" placeholder="e.g. https://ntfy.hallsworth.ca"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>ntfy Topic</td>
|
||||||
|
<td><input type="text" id="ntfy_topic" name="ntfy_topic" value="{{ ntfy.get('ntfy_topic', '') }}" placeholder="e.g. Troll-AI-ntfy"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>ntfy Access Token</td>
|
||||||
|
<td>
|
||||||
|
<input type="password" id="ntfy_token" name="ntfy_token" placeholder="{% if ntfy.get('ntfy_token') %}Stored encrypted — type to replace{% else %}Enter token (optional){% endif %}">
|
||||||
|
<p class="copy-hint">Required if the topic is access-controlled. Leave blank to keep the existing stored token.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button type="submit">Save ntfy Settings</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0;">
|
||||||
|
|
||||||
|
<h3>Send Test Notification</h3>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="send_test_ntfy">
|
||||||
|
<button type="submit">Send Test ntfy</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email / SMTP configuration -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Email (SMTP) Settings</h2>
|
||||||
|
<p class="copy-hint">These settings are used to send confirmation, preview, and delivery emails to customers. The password is stored encrypted using the Flask secret key.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_email_config">
|
||||||
|
<table class="meta-table">
|
||||||
|
<tr>
|
||||||
|
<td>SMTP Host</td>
|
||||||
|
<td><input type="text" id="smtp_host" name="smtp_host" value="{{ email_form.smtp_host }}" placeholder="e.g. mailroot8.namespro.ca"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SMTP Port</td>
|
||||||
|
<td><input type="text" id="smtp_port" name="smtp_port" value="{{ email_form.smtp_port }}" placeholder="e.g. 465"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SMTP Username</td>
|
||||||
|
<td><input type="text" id="smtp_user" name="smtp_user" value="{{ email_form.smtp_user }}" placeholder="e.g. ai@hallsworth.ca"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>From Address</td>
|
||||||
|
<td><input type="text" id="smtp_from" name="smtp_from" value="{{ email_form.smtp_from }}" placeholder="e.g. ai@hallsworth.ca"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SMTP Password</td>
|
||||||
|
<td>
|
||||||
|
<input type="password" id="smtp_pass" name="smtp_pass" placeholder="{% if email_form.smtp_pass_set %}Stored encrypted — type to replace{% else %}Enter password{% endif %}">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<button type="submit">Save Email Settings</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0;">
|
||||||
|
|
||||||
|
<h3>Send Test Email</h3>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="send_test_email">
|
||||||
|
<label for="test_email_address">Test recipient address</label>
|
||||||
|
<input type="email" id="test_email_address" name="test_email_address" placeholder="you@example.com" required>
|
||||||
|
<button type="submit">Send Test Email</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MP3 metadata defaults -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>MP3 Metadata Tags</h2>
|
||||||
|
<p class="copy-hint">These values are written into every uploaded MP3 file. The song title from the prompt is written to the Title tag automatically. Blank fields are skipped.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_metadata">
|
||||||
|
<table class="meta-table">
|
||||||
|
<tr>
|
||||||
|
<td>Artist</td>
|
||||||
|
<td><input type="text" id="artist" name="artist" value="{{ metadata.get('artist', '') or '' }}" placeholder="e.g. Trollgorithm"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Album</td>
|
||||||
|
<td><input type="text" id="album" name="album" value="{{ metadata.get('album', '') or '' }}" placeholder="e.g. Theme Song Booth 2026"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Year</td>
|
||||||
|
<td><input type="text" id="year" name="year" value="{{ metadata.get('year', '') or '' }}" placeholder="e.g. 2026"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Comment</td>
|
||||||
|
<td><textarea id="comment" name="comment" rows="3" placeholder="e.g. Custom theme song generated at the booth">{{ metadata.get('comment', '') or '' }}</textarea></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button type="submit">Save Metadata Defaults</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Database backup / restore -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Database Backup / Restore</h2>
|
||||||
|
<p class="copy-hint">Download a copy of the SQLite database before the event. Upload a previous backup to restore it; the current database will be renamed as a timestamped backup.</p>
|
||||||
|
|
||||||
|
<form method="POST" style="margin-bottom:1rem">
|
||||||
|
<input type="hidden" name="action" value="download_db">
|
||||||
|
<button type="submit">Download Database Backup</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="action" value="restore_db">
|
||||||
|
<label for="db_backup">Restore from backup (.db / .sqlite)</label>
|
||||||
|
<input type="file" id="db_backup" name="db_backup" accept=".db,.sqlite,.sqlite3">
|
||||||
|
<button type="submit" onclick="return confirm('This will replace the current database. The old one will be kept as a timestamped backup. Continue?')">Restore Database</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uploads backup -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Music Files Backup</h2>
|
||||||
|
<p class="copy-hint">Download all uploaded MP3 and archived revision files as a single ZIP.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="download_uploads_zip">
|
||||||
|
<button type="submit">Download Music Files ZIP</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System reset spans both columns -->
|
||||||
|
<div class="danger-zone">
|
||||||
|
<h2>⚠️ Reset System</h2>
|
||||||
|
<p>Use this only at the start of a new event. It will delete every request and every uploaded MP3 file. This cannot be undone.</p>
|
||||||
|
<form method="POST" action="{{ url_for('admin_settings') }}" onsubmit="return confirm('ARE YOU SURE? This will delete ALL requests and ALL uploaded files. This cannot be undone.')">
|
||||||
|
<input type="hidden" name="action" value="reset_system">
|
||||||
|
<button type="submit">Reset System</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyHermesKey() {
|
||||||
|
const el = document.getElementById('new-hermes-key');
|
||||||
|
if (!el) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNode(el);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
navigator.clipboard.writeText(el.textContent).then(() => {
|
||||||
|
alert('Hermes API key copied!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
49
templates/closed.html
Normal file
49
templates/closed.html
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Trollgorithm Theme Song Booth — Closed</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Closed booth page.
|
||||||
|
Friendly message shown when the booth is temporarily not taking requests.
|
||||||
|
*/
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%);
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.6;
|
||||||
|
min-height:100vh;
|
||||||
|
}
|
||||||
|
.container{max-width:680px;margin:0 auto;text-align:center;}
|
||||||
|
.banner{
|
||||||
|
width:100%;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 40px rgba(0,0,0,.4);
|
||||||
|
margin-bottom:1.5rem;
|
||||||
|
}
|
||||||
|
.card{
|
||||||
|
background:rgba(31,41,55,.9);
|
||||||
|
padding:1.5rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 25px rgba(0,0,0,.3);
|
||||||
|
border:1px solid rgba(96,165,250,.2);
|
||||||
|
}
|
||||||
|
h1{margin-top:0;color:#60a5fa;font-size:1.7rem;}
|
||||||
|
p{font-size:1.1rem;color:#d1d5db;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<img src="{{ url_for('static', filename='Booth_closed.png') }}" alt="Trollgorithm booth is closed" class="banner">
|
||||||
|
<div class="card">
|
||||||
|
<h1>Trollgorithm and the goblin engineers are on a break.</h1>
|
||||||
|
<p>Please come back in a while and they should be back to work!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
175
templates/faq.html
Normal file
175
templates/faq.html
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FAQ — Trollgorithm Theme Song Booth</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Customer FAQ page.
|
||||||
|
Simple, readable dark layout matching the request page style.
|
||||||
|
*/
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%);
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.6;
|
||||||
|
min-height:100vh;
|
||||||
|
}
|
||||||
|
.container{max-width:760px;margin:0 auto;}
|
||||||
|
.card{
|
||||||
|
background:rgba(31,41,55,.95);
|
||||||
|
padding:1.5rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 25px rgba(0,0,0,.3);
|
||||||
|
border:1px solid rgba(96,165,250,.2);
|
||||||
|
}
|
||||||
|
h1{
|
||||||
|
margin-top:0;
|
||||||
|
color:#60a5fa;
|
||||||
|
text-align:center;
|
||||||
|
font-size:1.9rem;
|
||||||
|
}
|
||||||
|
h2{
|
||||||
|
color:#93c5fd;
|
||||||
|
margin-top:2rem;
|
||||||
|
font-size:1.25rem;
|
||||||
|
border-bottom:1px solid #374151;
|
||||||
|
padding-bottom:.4rem;
|
||||||
|
}
|
||||||
|
h2:first-of-type{margin-top:0;}
|
||||||
|
p{margin:.6rem 0;}
|
||||||
|
strong{color:#fbbf24;}
|
||||||
|
a{color:#93c5fd;text-decoration:none;}
|
||||||
|
a:hover{text-decoration:underline;}
|
||||||
|
.back{
|
||||||
|
display:block;
|
||||||
|
text-align:center;
|
||||||
|
margin-top:1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<h1>❓ Frequently Asked Questions</h1>
|
||||||
|
|
||||||
|
<h2>The basics</h2>
|
||||||
|
<p><strong>What is the Trollgorithm Song Booth?</strong></p>
|
||||||
|
<p>We create a custom, AI-generated theme song just for you based on your personality, hobbies, and style preferences.</p>
|
||||||
|
|
||||||
|
<p><strong>How long does it take to get my song?</strong></p>
|
||||||
|
<p>Most songs are ready to preview within a few minutes to an hour, depending on how busy the booth is!</p>
|
||||||
|
|
||||||
|
<p><strong>How much does it cost?</strong></p>
|
||||||
|
<p>Pricing is displayed at the booth; you pay after listening to and choosing the version you want.</p>
|
||||||
|
|
||||||
|
<p><strong>What do I actually receive?</strong></p>
|
||||||
|
<p>You get an MP3 file of the version you selected, delivered straight to your email. WAV format is available for a slight additional charge.</p>
|
||||||
|
|
||||||
|
<h2>How it works</h2>
|
||||||
|
<p><strong>What info do I need to give you?</strong></p>
|
||||||
|
<p>Your name, email, hobbies, notable facts about you, a preferred style or genre, and any extra requests you want included.</p>
|
||||||
|
|
||||||
|
<p><strong>Can I pick the style or genre?</strong></p>
|
||||||
|
<p>Yes — tell us anything from “80s power ballad” to “cinematic orchestral” and we will aim for that vibe.</p>
|
||||||
|
|
||||||
|
<p><strong>What if I want a specific singer voice?</strong></p>
|
||||||
|
<p>You can select a vocal preference such as female, male, androgynous, or non-binary on the request form. We are unable to create specific singers due to copyright laws.</p>
|
||||||
|
|
||||||
|
<p><strong>Can I request specific lyrics or topics?</strong></p>
|
||||||
|
<p>Absolutely — put any specific lyrics, themes, or things to avoid in the “Extra requests” field.</p>
|
||||||
|
|
||||||
|
<p><strong>What happens after I submit the form?</strong></p>
|
||||||
|
<p>We generate a custom prompt, produce two versions of your song, and email you a private link to listen.</p>
|
||||||
|
|
||||||
|
<p><strong>How do I hear my song when it's ready?</strong></p>
|
||||||
|
<p>Click the private link in your email — it works on any phone, tablet, or computer.</p>
|
||||||
|
|
||||||
|
<h2>Versions & revisions</h2>
|
||||||
|
<p><strong>What's the difference between Version A and Version B?</strong></p>
|
||||||
|
<p>They are two different takes or arrangements of your theme song, so you can choose the one you like best.</p>
|
||||||
|
|
||||||
|
<p><strong>Can I get both versions?</strong></p>
|
||||||
|
<p>Yes — just select “I want both” on the preview page.</p>
|
||||||
|
|
||||||
|
<p><strong>What if I don't like either version?</strong></p>
|
||||||
|
<p>You can request a limited number of changes through the preview page, or speak to the booth operator.</p>
|
||||||
|
|
||||||
|
<p><strong>Can I ask for changes? How many times?</strong></p>
|
||||||
|
<p>Yes, a small number of revisions are allowed; the exact limit is shown on your preview page.</p>
|
||||||
|
|
||||||
|
<p><strong>What if I change my mind after picking a version?</strong></p>
|
||||||
|
<p>Let the booth operator know right away; if payment has not been finalized, we can usually fix it.</p>
|
||||||
|
|
||||||
|
<h2>Limitations</h2>
|
||||||
|
<p><strong>What limitations are there for topics or lyrics?</strong></p>
|
||||||
|
<p>Trollgorithm is a friendly band and will not produce lyrics that contain any of the following:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Specific acts of violence, self-harm, terrorism, or serious crime against identifiable people.</li>
|
||||||
|
<li>Help create, or refine CSAM or sexual content involving minors.</li>
|
||||||
|
<li>Anything hate-crime related.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>Can I use explicit lyrics?</strong></p>
|
||||||
|
<p>Yes! Trollgorithm believes the occasional use of explicit or crude language is fine, but the booth operator is a stick in the mud and won't allow minors to make music filled with swear words!</p>
|
||||||
|
|
||||||
|
<p><strong>Can you make a song using just lyrics I provide?</strong></p>
|
||||||
|
<p>Yes! Trollgorithm loves to collaborate with song writers! Ask the booth operator for help!</p>
|
||||||
|
|
||||||
|
<p><strong>What if I want to sing and they just play the music?</strong></p>
|
||||||
|
<p>While Trollgorithm is talented, they are also still just Trolls. You would probably get eaten if you were in the recording booth with them.</p>
|
||||||
|
|
||||||
|
<p><strong>What languages can Trollgorithm sing in?</strong></p>
|
||||||
|
<p>Trollgorithm knows English, Spanish, and French best. They can attempt German, Italian, Portuguese, Japanese, Mandarin Chinese, Korean, Russian, Arabic, and Hindi — but remember, they're just trolls and goblins, not terribly smart. Have a fluent speaker listen and check the result to make sure it sounds right.</p>
|
||||||
|
|
||||||
|
<h2>Payment & delivery</h2>
|
||||||
|
<p><strong>How do I pay?</strong></p>
|
||||||
|
<p>We accept payment at the booth through Square, including card, tap, and cash where available.</p>
|
||||||
|
|
||||||
|
<p><strong>Do you pay before or after hearing the song?</strong></p>
|
||||||
|
<p>You hear the preview first, pick a version, and then pay before the final MP3 is emailed to you.</p>
|
||||||
|
|
||||||
|
<p><strong>Where is my song delivered?</strong></p>
|
||||||
|
<p>To the email address you gave us, so double-check it for typos before submitting.</p>
|
||||||
|
|
||||||
|
<p><strong>What file format is it?</strong></p>
|
||||||
|
<p>The delivered song is a standard MP3 file that plays on virtually any device. WAV files can be provided upon request. We can also get you STEMS. If you know, you know, otherwise these aren't for everybody.</p>
|
||||||
|
|
||||||
|
<p><strong>Can you send it to someone else's email?</strong></p>
|
||||||
|
<p>At the booth we can update the email address if needed, but the original request needs a valid email to start.</p>
|
||||||
|
|
||||||
|
<h2>Ownership & usage</h2>
|
||||||
|
<p><strong>Do I own my theme song?</strong></p>
|
||||||
|
<p>Once you have paid, you do! You can use it for whatever you want, commercially or privately. Trollgorithm does reserve the right to use any music it makes as part of its demonstration playlist.</p>
|
||||||
|
|
||||||
|
<p><strong>Can I post it online?</strong></p>
|
||||||
|
<p>Yes, for personal, or commercial use — feel free to share it on social media.</p>
|
||||||
|
|
||||||
|
<p><strong>Is this AI-generated music? Is that legal?</strong></p>
|
||||||
|
<p>Trollgorithm is a privately built AI music Writer, Producer, Sound Engineer, and Band. It does each step (with human input, of course!) and produces finished songs. Since the music and lyrics are generated from scratch, no copyright rules are broken.</p>
|
||||||
|
|
||||||
|
<h2>Privacy & support</h2>
|
||||||
|
<p><strong>Is my personal information kept private?</strong></p>
|
||||||
|
<p>Yes, your info is only used to create and deliver your song and is not shared or sold. Your information is only stored for a few days, to ensure you have received the end product you want. After that it is deleted.</p>
|
||||||
|
|
||||||
|
<p><strong>What if I made a typo in my email?</strong></p>
|
||||||
|
<p>Tell the booth operator; they can edit your email address so your song reaches you.</p>
|
||||||
|
|
||||||
|
<p><strong>What if I don't get the email?</strong></p>
|
||||||
|
<p>Check your spam or junk folder first, then ask the operator to resend or verify your address.</p>
|
||||||
|
|
||||||
|
<p><strong>What if there's a technical problem?</strong></p>
|
||||||
|
<p>Let the booth operator know and we will do our best to fix it or retry delivery.</p>
|
||||||
|
|
||||||
|
<p><strong>Who do I talk to for help?</strong></p>
|
||||||
|
<p>The booth operator is your best point of contact for any questions or issues.</p>
|
||||||
|
|
||||||
|
<a class="back" href="{{ url_for('request_form') }}" target="_blank" rel="noopener noreferrer">← Back to the request form</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
322
templates/kiosk.html
Normal file
322
templates/kiosk.html
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Trollgorithm Booth — Kiosk</title>
|
||||||
|
{% if refresh_seconds and refresh_seconds > 0 %}
|
||||||
|
<meta http-equiv="refresh" content="{{ refresh_seconds }}">
|
||||||
|
{% endif %}
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
html,body{
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
height:100%;
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:linear-gradient(135deg,#0f172a 0%,#1e3a8a 50%,#0f172a 100%);
|
||||||
|
color:#f3f4f6;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.kiosk{
|
||||||
|
height:100%;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
padding:2vh 3vw;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
.banner{
|
||||||
|
width:100%;
|
||||||
|
max-height:22vh;
|
||||||
|
object-fit:contain;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 40px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.status-pill{
|
||||||
|
display:inline-block;
|
||||||
|
margin-top:1.5vh;
|
||||||
|
padding:.6rem 1.4rem;
|
||||||
|
border-radius:9999px;
|
||||||
|
font-weight:700;
|
||||||
|
font-size:1.2rem;
|
||||||
|
text-transform:uppercase;
|
||||||
|
letter-spacing:.05em;
|
||||||
|
}
|
||||||
|
.status-open{background:#10b981;color:#000;}
|
||||||
|
.status-closed{background:#ef4444;color:#000;}
|
||||||
|
.stage{
|
||||||
|
flex:1;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
width:100%;
|
||||||
|
max-width:900px;
|
||||||
|
margin:2vh 0;
|
||||||
|
}
|
||||||
|
.slide{
|
||||||
|
width:100%;
|
||||||
|
animation:fadeIn .8s ease;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn{
|
||||||
|
from{opacity:0;transform:translateY(20px);}
|
||||||
|
to{opacity:1;transform:translateY(0);}
|
||||||
|
}
|
||||||
|
.slide.hidden{display:none;}
|
||||||
|
h1{
|
||||||
|
margin:0 0 1.5vh 0;
|
||||||
|
font-size:clamp(2rem,5vw,3.5rem);
|
||||||
|
color:#60a5fa;
|
||||||
|
}
|
||||||
|
.qr-code{
|
||||||
|
max-height:44vh;
|
||||||
|
max-width:44vh;
|
||||||
|
width:auto;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 40px rgba(0,0,0,.4);
|
||||||
|
background:#fff;
|
||||||
|
padding:.6rem;
|
||||||
|
}
|
||||||
|
.qr-caption{
|
||||||
|
margin-top:1.5vh;
|
||||||
|
font-size:clamp(1.1rem,2.5vw,1.6rem);
|
||||||
|
color:#93c5fd;
|
||||||
|
}
|
||||||
|
.price-card{
|
||||||
|
background:rgba(31,41,55,.85);
|
||||||
|
border:1px solid rgba(96,165,250,.25);
|
||||||
|
border-radius:1.2rem;
|
||||||
|
padding:3vh 4vw;
|
||||||
|
box-shadow:0 15px 50px rgba(0,0,0,.35);
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
.price-card h2{
|
||||||
|
margin:0 0 2vh 0;
|
||||||
|
font-size:clamp(1.6rem,4vw,2.6rem);
|
||||||
|
color:#fbbf24;
|
||||||
|
}
|
||||||
|
.price-list{
|
||||||
|
list-style:none;
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
text-align:left;
|
||||||
|
}
|
||||||
|
.price-list li{
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
align-items:center;
|
||||||
|
border-bottom:1px solid rgba(147,197,253,.2);
|
||||||
|
padding:1.2vh 0;
|
||||||
|
font-size:clamp(1.2rem,3vw,1.8rem);
|
||||||
|
}
|
||||||
|
.price-list li:last-child{border-bottom:none;}
|
||||||
|
.price-list .label{color:#e5e7eb;}
|
||||||
|
.price-list .amount{font-weight:700;color:#fbbf24;}
|
||||||
|
.price-empty{
|
||||||
|
font-size:1.3rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
}
|
||||||
|
.queue-card{
|
||||||
|
background:rgba(31,41,55,.85);
|
||||||
|
border:1px solid rgba(96,165,250,.25);
|
||||||
|
border-radius:1.2rem;
|
||||||
|
padding:3vh 4vw;
|
||||||
|
box-shadow:0 15px 50px rgba(0,0,0,.35);
|
||||||
|
width:100%;
|
||||||
|
max-height:62vh;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
}
|
||||||
|
.queue-card h2{
|
||||||
|
margin:0 0 2vh 0;
|
||||||
|
font-size:clamp(1.6rem,4vw,2.6rem);
|
||||||
|
color:#fbbf24;
|
||||||
|
}
|
||||||
|
.queue-list{
|
||||||
|
list-style:none;
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
text-align:left;
|
||||||
|
overflow-y:auto;
|
||||||
|
flex:1;
|
||||||
|
}
|
||||||
|
.queue-list li{
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
align-items:center;
|
||||||
|
border-bottom:1px solid rgba(147,197,253,.2);
|
||||||
|
padding:1.4vh 0;
|
||||||
|
font-size:clamp(1.2rem,2.8vw,1.7rem);
|
||||||
|
}
|
||||||
|
.queue-list li:last-child{border-bottom:none;}
|
||||||
|
.queue-list .customer{color:#e5e7eb;font-weight:600;}
|
||||||
|
.queue-list .status{font-weight:700;color:#60a5fa;text-align:right;}
|
||||||
|
.queue-empty{
|
||||||
|
font-size:1.4rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
text-align:center;
|
||||||
|
margin:auto 0;
|
||||||
|
}
|
||||||
|
.queue-note{
|
||||||
|
font-size:1rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
margin-top:1.5vh;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
.footer{
|
||||||
|
font-size:1rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
}
|
||||||
|
.dots{
|
||||||
|
display:flex;
|
||||||
|
gap:.6rem;
|
||||||
|
justify-content:center;
|
||||||
|
margin-top:1vh;
|
||||||
|
}
|
||||||
|
.dot{
|
||||||
|
width:12px;
|
||||||
|
height:12px;
|
||||||
|
border-radius:50%;
|
||||||
|
background:#374151;
|
||||||
|
transition:background .3s;
|
||||||
|
}
|
||||||
|
.dot.active{background:#60a5fa;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="kiosk">
|
||||||
|
<div class="header">
|
||||||
|
{% if booth_open %}
|
||||||
|
<img src="{{ url_for('static', filename='Trollgorithm_booth.jpg') }}" alt="Trollgorithm Theme Song Booth" class="banner">
|
||||||
|
<div class="status-pill status-open">Booth is Open ✅</div>
|
||||||
|
{% else %}
|
||||||
|
<img src="{{ url_for('static', filename='Booth_closed.png') }}" alt="Booth Closed" class="banner">
|
||||||
|
<div class="status-pill status-closed">Booth is Closed ❌</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage" id="stage">
|
||||||
|
{% if mode == 'qr' %}
|
||||||
|
<div class="slide">
|
||||||
|
<h1>Scan to Order</h1>
|
||||||
|
<img src="{{ url_for('static', filename='qr-code.png') }}" alt="Scan to order" class="qr-code">
|
||||||
|
<p class="qr-caption">Point your camera at the code to start your request.</p>
|
||||||
|
</div>
|
||||||
|
{% elif mode == 'prices' %}
|
||||||
|
<div class="slide">
|
||||||
|
<div class="price-card">
|
||||||
|
<h2>Pricing</h2>
|
||||||
|
{% if price_items %}
|
||||||
|
<ul class="price-list">
|
||||||
|
{% for item in price_items %}
|
||||||
|
<li>
|
||||||
|
<span class="label">{{ item.label }}</span>
|
||||||
|
<span class="amount">{{ item.price }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="price-empty">Pricing coming soon. Ask the booth operator!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif mode == 'queue' %}
|
||||||
|
<div class="slide">
|
||||||
|
<div class="queue-card">
|
||||||
|
<h2>Live Queue</h2>
|
||||||
|
{% if queue %}
|
||||||
|
<ul class="queue-list">
|
||||||
|
{% for item in queue %}
|
||||||
|
<li>
|
||||||
|
<span class="customer">#{{ item.id }} {{ item.name }}</span>
|
||||||
|
<span class="status">{{ item.status }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p class="queue-note">If your request isn't shown, visit the request page and use the status link to check your place.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="queue-empty">No active requests right now. Be the first!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="slide" id="slide-qr">
|
||||||
|
<h1>Scan to Order</h1>
|
||||||
|
<img src="{{ url_for('static', filename='qr-code.png') }}" alt="Scan to order" class="qr-code">
|
||||||
|
<p class="qr-caption">Point your camera at the code to start your request.</p>
|
||||||
|
</div>
|
||||||
|
<div class="slide hidden" id="slide-prices">
|
||||||
|
<div class="price-card">
|
||||||
|
<h2>Pricing</h2>
|
||||||
|
{% if price_items %}
|
||||||
|
<ul class="price-list">
|
||||||
|
{% for item in price_items %}
|
||||||
|
<li>
|
||||||
|
<span class="label">{{ item.label }}</span>
|
||||||
|
<span class="amount">{{ item.price }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="price-empty">Pricing coming soon. Ask the booth operator!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="slide hidden" id="slide-queue">
|
||||||
|
<div class="queue-card">
|
||||||
|
<h2>Live Queue</h2>
|
||||||
|
{% if queue %}
|
||||||
|
<ul class="queue-list">
|
||||||
|
{% for item in queue %}
|
||||||
|
<li>
|
||||||
|
<span class="customer">#{{ item.id }} {{ item.name }}</span>
|
||||||
|
<span class="status">{{ item.status }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p class="queue-note">If your request isn't shown, visit the request page and use the status link to check your place.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="queue-empty">No active requests right now. Be the first!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if mode == 'cycle' %}
|
||||||
|
<div class="footer">
|
||||||
|
<div class="dots">
|
||||||
|
<div class="dot active" id="dot-qr"></div>
|
||||||
|
<div class="dot" id="dot-prices"></div>
|
||||||
|
<div class="dot" id="dot-queue"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const qr = document.getElementById('slide-qr');
|
||||||
|
const prices = document.getElementById('slide-prices');
|
||||||
|
const queue = document.getElementById('slide-queue');
|
||||||
|
const dotQr = document.getElementById('dot-qr');
|
||||||
|
const dotPrices = document.getElementById('dot-prices');
|
||||||
|
const dotQueue = document.getElementById('dot-queue');
|
||||||
|
const seconds = {{ cycle_seconds }};
|
||||||
|
const slides = [qr, prices, queue];
|
||||||
|
const dots = [dotQr, dotPrices, dotQueue];
|
||||||
|
if (seconds > 0) {
|
||||||
|
let idx = 0;
|
||||||
|
setInterval(function(){
|
||||||
|
slides[idx].classList.add('hidden');
|
||||||
|
dots[idx].classList.remove('active');
|
||||||
|
idx = (idx + 1) % slides.length;
|
||||||
|
slides[idx].classList.remove('hidden');
|
||||||
|
dots[idx].classList.add('active');
|
||||||
|
}, seconds * 1000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
207
templates/player.html
Normal file
207
templates/player.html
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Your Theme Song</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Private customer player page.
|
||||||
|
Shows two audio players for Version A and Version B, plus approval
|
||||||
|
buttons or a revision note form depending on request status.
|
||||||
|
After the customer makes a choice, the controls are hidden and a
|
||||||
|
confirmation/waiting message is shown. The number of remaining
|
||||||
|
customer revisions is displayed when revisions are enabled.
|
||||||
|
*/
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.5;
|
||||||
|
}
|
||||||
|
.container{
|
||||||
|
max-width:640px;
|
||||||
|
margin:0 auto;
|
||||||
|
background:#1f2937;
|
||||||
|
padding:1.5rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
}
|
||||||
|
h1{color:#60a5fa;}
|
||||||
|
.player{
|
||||||
|
background:#111827;
|
||||||
|
padding:1rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin:1rem 0;
|
||||||
|
}
|
||||||
|
audio{width:100%;margin-top:.5rem;}
|
||||||
|
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1rem;}
|
||||||
|
button{
|
||||||
|
flex:1;
|
||||||
|
min-width:120px;
|
||||||
|
padding:.8rem;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background:#3b82f6;
|
||||||
|
color:#fff;
|
||||||
|
font-weight:700;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
button:disabled{background:#374151;color:#9ca3af;cursor:not-allowed;}
|
||||||
|
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;}
|
||||||
|
.locked{
|
||||||
|
margin-top:1rem;
|
||||||
|
padding:1rem;
|
||||||
|
background:#111827;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
}
|
||||||
|
.locked h2{margin-top:0;color:#fbbf24;}
|
||||||
|
.locked p{margin:.3rem 0;}
|
||||||
|
.lyrics{
|
||||||
|
background:#111827;
|
||||||
|
border:1px solid #374151;
|
||||||
|
border-radius:.5rem;
|
||||||
|
padding:1rem 1.25rem;
|
||||||
|
margin:1rem 0;
|
||||||
|
line-height:1.3;
|
||||||
|
font-size:1rem;
|
||||||
|
}
|
||||||
|
.lyrics h2{
|
||||||
|
margin-top:0;
|
||||||
|
margin-bottom:.5rem;
|
||||||
|
color:#fbbf24;
|
||||||
|
font-size:1.2rem;
|
||||||
|
}
|
||||||
|
.lyrics .section-label{
|
||||||
|
color:#60a5fa;
|
||||||
|
font-weight:700;
|
||||||
|
display:block;
|
||||||
|
margin-top:.5rem;
|
||||||
|
margin-bottom:.1rem;
|
||||||
|
}
|
||||||
|
.lyrics .lyric-line{
|
||||||
|
display:block;
|
||||||
|
line-height:1.3;
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🎧 Your Custom Theme Song</h1>
|
||||||
|
<p>Hi {{ req.name }}! Listen to both versions and pick the one you want.</p>
|
||||||
|
|
||||||
|
{% if req.suno_lyrics %}
|
||||||
|
<div class="lyrics">
|
||||||
|
<h2>🎵 Song Lyrics</h2>
|
||||||
|
{% for line in req.suno_lyrics.splitlines() %}
|
||||||
|
{% set line = line.strip() %}
|
||||||
|
{% if line and not (line.startswith('[') and line.endswith(']')) %}
|
||||||
|
<span class="lyric-line">{{ line }}</span>
|
||||||
|
{% elif not line %}
|
||||||
|
<br>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Version A player -->
|
||||||
|
<div class="player">
|
||||||
|
<h3>Version A</h3>
|
||||||
|
<audio controls controlsList="nodownload" id="player-a"></audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version B player -->
|
||||||
|
<div class="player">
|
||||||
|
<h3>Version B</h3>
|
||||||
|
<audio controls controlsList="nodownload" id="player-b"></audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// Audio sources are assigned via JS so they do not appear in static HTML source.
|
||||||
|
document.getElementById('player-a').src = "{{ url_for('stream_audio', token=req.player_token, version='a') }}";
|
||||||
|
document.getElementById('player-b').src = "{{ url_for('stream_audio', token=req.player_token, version='b') }}";
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% if req.status == 'revisions_requested' %}
|
||||||
|
<!-- Customer asked for changes; lock the page and tell them to wait -->
|
||||||
|
<div class="locked waiting">
|
||||||
|
<h2>📝 Revision Requested</h2>
|
||||||
|
<p>You asked for changes. We will generate a new version and update this page.</p>
|
||||||
|
<p><strong>Your note:</strong> {{ req.revision_note }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif req.status in ['awaiting_payment','paid','delivered'] %}
|
||||||
|
<!-- Customer already picked a version; show their choice and payment instruction -->
|
||||||
|
<div class="locked">
|
||||||
|
<h2>✅ Choice Received</h2>
|
||||||
|
<p>You selected: <strong>{% if req.customer_approved == 'both' %}Both Versions{% else %}Version {{ (req.customer_approved or 'none').upper() }}{% endif %}</strong></p>
|
||||||
|
{% if req.status == 'awaiting_payment' %}
|
||||||
|
<p>Please return to the booth to finalize payment and collect your files.</p>
|
||||||
|
{% elif req.status == 'paid' %}
|
||||||
|
<p>Payment recorded. Your files are being prepared.</p>
|
||||||
|
{% elif req.status == 'delivered' %}
|
||||||
|
<p>Delivered! Check your email for the MP3 attachment(s).</p>
|
||||||
|
{% if req.stems_link %}
|
||||||
|
<p style="margin-top:.8rem">
|
||||||
|
<a href="{{ req.stems_link }}" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:.7rem 1rem;background:#10b981;color:#000;border-radius:.5rem;text-decoration:none;font-weight:700;">Download stems / extras →</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:.9rem;color:#9ca3af;">This link expires 3 months after delivery. Download soon.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Approval form: customer picks Version A, B, or both -->
|
||||||
|
<form method="POST" action="{{ url_for('approve', token=req.player_token) }}">
|
||||||
|
<input type="hidden" name="choice" id="choice">
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" onclick="document.getElementById('choice').value='a'">I want Version A</button>
|
||||||
|
<button type="submit" onclick="document.getElementById('choice').value='b'">I want Version B</button>
|
||||||
|
<button type="submit" class="both" onclick="document.getElementById('choice').value='both'">I want both</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if revisions_left > 0 %}
|
||||||
|
<!-- Revision form: customer asks for changes -->
|
||||||
|
<form method="POST" action="{{ url_for('revise', token=req.player_token) }}">
|
||||||
|
<p style="color:#93c5fd;font-weight:600">Revisions remaining: {{ revisions_left }}</p>
|
||||||
|
<label for="revision_note">Or ask for changes:</label>
|
||||||
|
<textarea id="revision_note" name="revision_note" placeholder="e.g. make the chorus louder, swap a lyric..."></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="revision">Request Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p style="margin-top:1rem;color:#f87171;font-weight:600">No revisions remaining. Please speak to the booth operator if you need further changes.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
224
templates/request.html
Normal file
224
templates/request.html
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Request Your Theme Song</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Customer-facing request page.
|
||||||
|
Designed for convention booth tablets/phones. Shows the banner image and
|
||||||
|
a styled card form for name, email, hobbies, facts, style, vocal gender,
|
||||||
|
and extra requests. Hidden when the booth is marked closed.
|
||||||
|
*/
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%);
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.5;
|
||||||
|
min-height:100vh;
|
||||||
|
}
|
||||||
|
.container{max-width:680px;margin:0 auto;}
|
||||||
|
.banner{
|
||||||
|
width:100%;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 40px rgba(0,0,0,.4);
|
||||||
|
margin-bottom:1.5rem;
|
||||||
|
}
|
||||||
|
.card{
|
||||||
|
background:rgba(31,41,55,.9);
|
||||||
|
padding:1.5rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 25px rgba(0,0,0,.3);
|
||||||
|
border:1px solid rgba(96,165,250,.2);
|
||||||
|
}
|
||||||
|
h1{
|
||||||
|
margin-top:0;
|
||||||
|
color:#60a5fa;
|
||||||
|
text-align:center;
|
||||||
|
font-size:1.7rem;
|
||||||
|
}
|
||||||
|
.subtitle{
|
||||||
|
text-align:center;
|
||||||
|
color:#9ca3af;
|
||||||
|
margin-bottom:1.5rem;
|
||||||
|
}
|
||||||
|
label{
|
||||||
|
display:block;
|
||||||
|
margin-top:1rem;
|
||||||
|
font-weight:600;
|
||||||
|
color:#93c5fd;
|
||||||
|
}
|
||||||
|
input,textarea{
|
||||||
|
width:100%;
|
||||||
|
padding:.7rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
font:inherit;
|
||||||
|
margin-top:.25rem;
|
||||||
|
}
|
||||||
|
select{
|
||||||
|
width:100%;
|
||||||
|
padding:.7rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
font:inherit;
|
||||||
|
margin-top:.25rem;
|
||||||
|
}
|
||||||
|
input:focus,textarea:focus,select:focus{
|
||||||
|
outline:none;
|
||||||
|
border-color:#60a5fa;
|
||||||
|
box-shadow:0 0 0 3px rgba(96,165,250,.2);
|
||||||
|
}
|
||||||
|
textarea{min-height:90px;resize:vertical;}
|
||||||
|
button{
|
||||||
|
margin-top:1.5rem;
|
||||||
|
width:100%;
|
||||||
|
padding:1rem;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background:linear-gradient(90deg,#3b82f6,#8b5cf6);
|
||||||
|
color:#fff;
|
||||||
|
font-weight:700;
|
||||||
|
font-size:1.1rem;
|
||||||
|
cursor:pointer;
|
||||||
|
box-shadow:0 4px 15px rgba(59,130,246,.4);
|
||||||
|
transition:transform .1s,box-shadow .1s;
|
||||||
|
}
|
||||||
|
button:hover{
|
||||||
|
transform:translateY(-2px);
|
||||||
|
box-shadow:0 6px 20px rgba(139,92,246,.5);
|
||||||
|
}
|
||||||
|
.note{
|
||||||
|
margin-top:1rem;
|
||||||
|
font-size:.9rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
.sparkle{
|
||||||
|
font-size:1.3rem;
|
||||||
|
vertical-align:middle;
|
||||||
|
}
|
||||||
|
.flash{
|
||||||
|
padding:.8rem;
|
||||||
|
background:#064e3b;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.flash.error{background:#450a0a;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Banner graphic for the booth. Served from /static. -->
|
||||||
|
<img src="{{ url_for('static', filename='Trollgorithm_booth.jpg') }}" alt="Trollgorithm Theme Song Booth" class="banner">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<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"><a href="{{ url_for('faq') }}" target="_blank" rel="noopener noreferrer">Questions? Read our FAQ →</a> · <a href="{{ url_for('status_lookup') }}" target="_blank" rel="noopener noreferrer">Already ordered? Check status →</a></p>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<label for="email">Email address</label>
|
||||||
|
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
|
||||||
|
|
||||||
|
<label for="name">Name / persona you want in the song</label>
|
||||||
|
<input type="text" id="name" name="name" value="{{ form.name if form else '' }}" required>
|
||||||
|
|
||||||
|
<label for="pronouns">Pronouns <span style="color:#f87171">*</span></label>
|
||||||
|
<select id="pronouns" name="pronouns" required>
|
||||||
|
<option value="" {% if not form or not form.pronouns %}selected{% endif %}>— Select pronouns —</option>
|
||||||
|
<option value="He/Him/His" {% if form and form.pronouns == 'He/Him/His' %}selected{% endif %}>He/Him/His</option>
|
||||||
|
<option value="She/Her/Hers" {% if form and form.pronouns == 'She/Her/Hers' %}selected{% endif %}>She/Her/Hers</option>
|
||||||
|
<option value="They/Them/Their" {% if form and form.pronouns == 'They/Them/Their' %}selected{% endif %}>They/Them/Their</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="hobbies">Hobbies & interests</label>
|
||||||
|
<textarea id="hobbies" name="hobbies" maxlength="2000" placeholder="e.g. rock climbing, retro gaming, sourdough baking">{{ form.hobbies if form else '' }}</textarea>
|
||||||
|
|
||||||
|
<label for="notable_facts">Notable things about you</label>
|
||||||
|
<textarea id="notable_facts" name="notable_facts" maxlength="2000" placeholder="Anything fun, weird, or heroic we should mention">{{ form.notable_facts if form else '' }}</textarea>
|
||||||
|
|
||||||
|
<label for="decade">Decade / era <span style="color:#f87171">*</span></label>
|
||||||
|
<select id="decade" name="decade" required>
|
||||||
|
<option value="" {% if not form or not form.decade %}selected{% endif %}>— Select decade —</option>
|
||||||
|
{% for d in decades %}
|
||||||
|
<option value="{{ d }}" {% if form and form.decade == d %}selected{% endif %}>{{ d }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="basic_style">Basic style <span style="color:#f87171">*</span></label>
|
||||||
|
<select id="basic_style" name="basic_style" required>
|
||||||
|
<option value="" {% if not form or not form.basic_style %}selected{% endif %}>— Select style —</option>
|
||||||
|
{% for g in genres %}
|
||||||
|
<option value="{{ g }}" {% if form and form.basic_style == g %}selected{% endif %}>{{ g }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="additional_style">Additional style (optional)</label>
|
||||||
|
<select id="additional_style" name="additional_style">
|
||||||
|
<option value="" {% if not form or not form.additional_style %}selected{% endif %}>— None —</option>
|
||||||
|
{% for g in genres %}
|
||||||
|
<option value="{{ g }}" {% if form and form.additional_style == g %}selected{% endif %}>{{ g }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<p style="font-size:.85rem;color:#9ca3af;margin-top:.3rem">Selected style: <span id="style-preview">{% if form %}{{ form.decade or '' }}{% if form.decade and form.basic_style %}, {% endif %}{{ form.basic_style or '' }}{% if form.basic_style and form.additional_style %}, {% endif %}{{ form.additional_style or '' }}{% else %}—{% endif %}</span></p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Live preview of the combined style string as the customer changes dropdowns.
|
||||||
|
(function(){
|
||||||
|
const decade = document.getElementById('decade');
|
||||||
|
const basic = document.getElementById('basic_style');
|
||||||
|
const additional = document.getElementById('additional_style');
|
||||||
|
const preview = document.getElementById('style-preview');
|
||||||
|
function updatePreview(){
|
||||||
|
const parts = [decade.value, basic.value, additional.value].filter(Boolean);
|
||||||
|
preview.textContent = parts.length ? parts.join(', ') : '—';
|
||||||
|
}
|
||||||
|
decade.addEventListener('change', updatePreview);
|
||||||
|
basic.addEventListener('change', updatePreview);
|
||||||
|
additional.addEventListener('change', updatePreview);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label for="vocal_gender">Preferred singer voice / gender</label>
|
||||||
|
<select id="vocal_gender" name="vocal_gender">
|
||||||
|
<option value="" {% if not form or not form.vocal_gender %}selected{% endif %}>No preference</option>
|
||||||
|
<option value="female" {% if form and form.vocal_gender == 'female' %}selected{% endif %}>Female</option>
|
||||||
|
<option value="male" {% if form and form.vocal_gender == 'male' %}selected{% endif %}>Male</option>
|
||||||
|
<option value="non-binary" {% if form and form.vocal_gender == 'non-binary' %}selected{% endif %}>Non-binary / Gender-neutral</option>
|
||||||
|
<option value="androgynous" {% if form and form.vocal_gender == 'androgynous' %}selected{% endif %}>Androgynous</option>
|
||||||
|
<option value="other" {% if form and form.vocal_gender == 'other' %}selected{% endif %}>Other (mention in Extra Requests)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="extra_requests">Anything else you want in the song? (Trollgorithm is very literal, so be careful!)</label>
|
||||||
|
<textarea id="extra_requests" name="extra_requests" maxlength="2000" placeholder="Specific Lyrics, clean/explicit...">{{ form.extra_requests if form else '' }}</textarea>
|
||||||
|
|
||||||
|
<label for="stems_interest" style="display:flex;align-items:center;gap:.5rem;margin-top:1rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="stems_interest" name="stems_interest" value="1" {% if form and form.stems_interest %}checked{% endif %} style="width:auto;margin:0">
|
||||||
|
I'm interested in STEMS / multitrack files (if you know, you know)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Submit Request</button>
|
||||||
|
</form>
|
||||||
|
<p class="note">Your info is only used to create and deliver your song.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
191
templates/status.html
Normal file
191
templates/status.html
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Check Your Order Status — Trollgorithm Theme Song Booth</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Public order status lookup page.
|
||||||
|
Customers enter their email to see request status, approval choice,
|
||||||
|
and the private player link once two songs have been uploaded.
|
||||||
|
*/
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:linear-gradient(135deg,#111827 0%,#1e3a8a 50%,#111827 100%);
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.5;
|
||||||
|
min-height:100vh;
|
||||||
|
}
|
||||||
|
.container{max-width:680px;margin:0 auto;}
|
||||||
|
.banner{
|
||||||
|
width:100%;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 40px rgba(0,0,0,.4);
|
||||||
|
margin-bottom:1.5rem;
|
||||||
|
}
|
||||||
|
.card{
|
||||||
|
background:rgba(31,41,55,.9);
|
||||||
|
padding:1.5rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 25px rgba(0,0,0,.3);
|
||||||
|
border:1px solid rgba(96,165,250,.2);
|
||||||
|
}
|
||||||
|
h1{
|
||||||
|
margin-top:0;
|
||||||
|
color:#60a5fa;
|
||||||
|
text-align:center;
|
||||||
|
font-size:1.7rem;
|
||||||
|
}
|
||||||
|
.subtitle{
|
||||||
|
text-align:center;
|
||||||
|
color:#9ca3af;
|
||||||
|
margin-bottom:1.5rem;
|
||||||
|
}
|
||||||
|
label{
|
||||||
|
display:block;
|
||||||
|
margin-top:1rem;
|
||||||
|
font-weight:600;
|
||||||
|
color:#93c5fd;
|
||||||
|
}
|
||||||
|
input{
|
||||||
|
width:100%;
|
||||||
|
padding:.7rem;
|
||||||
|
border-radius:.5rem;
|
||||||
|
border:1px solid #374151;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
font:inherit;
|
||||||
|
margin-top:.25rem;
|
||||||
|
}
|
||||||
|
input:focus{
|
||||||
|
outline:none;
|
||||||
|
border-color:#60a5fa;
|
||||||
|
box-shadow:0 0 0 3px rgba(96,165,250,.2);
|
||||||
|
}
|
||||||
|
button{
|
||||||
|
margin-top:1.5rem;
|
||||||
|
width:100%;
|
||||||
|
padding:1rem;
|
||||||
|
border:none;
|
||||||
|
border-radius:.5rem;
|
||||||
|
background:linear-gradient(90deg,#3b82f6,#8b5cf6);
|
||||||
|
color:#fff;
|
||||||
|
font-weight:700;
|
||||||
|
font-size:1.1rem;
|
||||||
|
cursor:pointer;
|
||||||
|
box-shadow:0 4px 15px rgba(59,130,246,.4);
|
||||||
|
transition:transform .1s,box-shadow .1s;
|
||||||
|
}
|
||||||
|
button:hover{
|
||||||
|
transform:translateY(-2px);
|
||||||
|
box-shadow:0 6px 20px rgba(139,92,246,.5);
|
||||||
|
}
|
||||||
|
.flash{
|
||||||
|
padding:.8rem;
|
||||||
|
background:#064e3b;
|
||||||
|
border-radius:.5rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.flash.error{background:#450a0a;}
|
||||||
|
.result{
|
||||||
|
margin-top:1.5rem;
|
||||||
|
}
|
||||||
|
.request{
|
||||||
|
background:#111827;
|
||||||
|
border:1px solid #374151;
|
||||||
|
border-radius:.75rem;
|
||||||
|
padding:1rem;
|
||||||
|
margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.request h3{margin-top:0;color:#93c5fd;}
|
||||||
|
.status{
|
||||||
|
display:inline-block;
|
||||||
|
padding:.25rem .6rem;
|
||||||
|
border-radius:9999px;
|
||||||
|
font-size:.85rem;
|
||||||
|
font-weight:600;
|
||||||
|
background:#374151;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:.3rem 0;
|
||||||
|
}
|
||||||
|
.status.pending,.status.prompt_ready{background:#60a5fa;color:#000;}
|
||||||
|
.status.songs_uploaded{background:#a78bfa;color:#000;}
|
||||||
|
.status.awaiting_payment{background:#f59e0b;color:#000;}
|
||||||
|
.status.paid,.status.delivered{background:#10b981;color:#000;}
|
||||||
|
.status.revisions_requested{background:#f87171;color:#000;}
|
||||||
|
.link-button{
|
||||||
|
display:inline-block;
|
||||||
|
margin-top:.5rem;
|
||||||
|
padding:.6rem 1rem;
|
||||||
|
background:#3b82f6;
|
||||||
|
border-radius:.5rem;
|
||||||
|
color:#fff;
|
||||||
|
text-decoration:none;
|
||||||
|
font-weight:600;
|
||||||
|
}
|
||||||
|
.approved{font-weight:600;color:#fbbf24;}
|
||||||
|
.back{
|
||||||
|
display:block;
|
||||||
|
text-align:center;
|
||||||
|
margin-top:1.5rem;
|
||||||
|
color:#93c5fd;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
.back:hover{text-decoration:underline;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<img src="{{ url_for('static', filename='Trollgorithm_booth.jpg') }}" alt="Trollgorithm Theme Song Booth" class="banner">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1>🔍 Check Your Order Status</h1>
|
||||||
|
<p class="subtitle">Enter the email address you used when you requested your song.</p>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<label for="email">Email address</label>
|
||||||
|
<input type="email" id="email" name="email" value="{{ email }}" required>
|
||||||
|
<button type="submit">Look Up My Song</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if searched %}
|
||||||
|
<div class="result">
|
||||||
|
{% if requests %}
|
||||||
|
{% for req in requests %}
|
||||||
|
<div class="request">
|
||||||
|
<h3>Request #{{ req.id }} — {{ req.name }}</h3>
|
||||||
|
<p><strong>Status:</strong> <span class="status {{ req.status }}">{{ statuses[req.status] }}</span></p>
|
||||||
|
|
||||||
|
{% if req.customer_approved and req.customer_approved != 'none' %}
|
||||||
|
<p class="approved">You selected: {% if req.customer_approved == 'both' %}Both Versions{% else %}Version {{ (req.customer_approved or 'none').upper() }}{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if req.song_a_path and req.song_b_path %}
|
||||||
|
<p><strong>Your preview link:</strong></p>
|
||||||
|
<a class="link-button" href="{{ url_for('play', token=req.player_token) }}" target="_blank" rel="noopener noreferrer">🎧 Listen & Pick Your Version</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p>No requests found for that email address. Make sure you used the same email you gave us at the booth.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="back" href="{{ url_for('request_form') }}">← Back to the request form</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
templates/thanks.html
Normal file
44
templates/thanks.html
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Request Received</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
Simple confirmation page shown after a customer submits a request.
|
||||||
|
Gives them a request number they can reference at the booth.
|
||||||
|
*/
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:#111827;
|
||||||
|
color:#f3f4f6;
|
||||||
|
margin:0;
|
||||||
|
padding:1rem;
|
||||||
|
line-height:1.5;
|
||||||
|
display:flex;
|
||||||
|
justify-content:center;
|
||||||
|
align-items:center;
|
||||||
|
min-height:100vh;
|
||||||
|
}
|
||||||
|
.container{
|
||||||
|
max-width:640px;
|
||||||
|
margin:0 auto;
|
||||||
|
background:#1f2937;
|
||||||
|
padding:1.5rem;
|
||||||
|
border-radius:1rem;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
h1{color:#34d399;}
|
||||||
|
.note{font-size:.9rem;color:#9ca3af;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>✅ Request Received</h1>
|
||||||
|
<p>Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.</p>
|
||||||
|
<p><strong>Your request number:</strong> #{{ req.id }}</p>
|
||||||
|
<p class="note">Bring this number to the booth if you want to check on progress.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue