353 lines
17 KiB
HTML
353 lines
17 KiB
HTML
<!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>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;700;800&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/booth.css') }}">
|
|
</head>
|
|
<body class="booth-page">
|
|
<div class="container container-wide">
|
|
<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">
|
|
|
|
<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>
|
|
|
|
<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:var(--space-2)">
|
|
<input type="hidden" name="action" value="fix_db">
|
|
<button type="submit" class="button-secondary">Recheck / Apply Schema</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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 class="divider">
|
|
|
|
<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>
|
|
|
|
<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 class="divider">
|
|
|
|
<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>
|
|
|
|
<div class="section">
|
|
<h2>Hermes Webhook</h2>
|
|
<p class="copy-hint">Push song request data to Hermes so it can generate a MusicGPT prompt automatically. The URL must include the route name (e.g. https://ntfy.hallsworth.ca/webhooks/trollgorithm-song-info). The secret is encrypted before storage.</p>
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="save_hermes_webhook">
|
|
<table class="meta-table">
|
|
<tr>
|
|
<td>Webhook URL</td>
|
|
<td><input type="text" id="hermes_webhook_url" name="hermes_webhook_url" value="{{ hermes_webhook.get('url', '') }}" placeholder="https://ntfy.hallsworth.ca/webhooks/trollgorithm-song-info"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>HMAC Secret</td>
|
|
<td>
|
|
<input type="password" id="hermes_webhook_secret" name="hermes_webhook_secret" placeholder="{% if hermes_webhook.get('secret') %}Stored encrypted — type to replace{% else %}Enter secret{% endif %}">
|
|
<p class="copy-hint">From the Hermes webhook subscription. Leave blank to keep the existing stored secret.</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<button type="submit">Save Hermes Webhook</button>
|
|
</form>
|
|
|
|
<hr class="divider">
|
|
|
|
<h3>Send Test Request</h3>
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="send_test_hermes_webhook">
|
|
<button type="submit">Send Test to Hermes</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>MusicGPT Integration</h2>
|
|
<p class="copy-hint">API key is set via the <code>MUSICGPT_API_KEY</code> environment variable in Docker.</p>
|
|
<p><strong>API key configured:</strong> {{ '✅ Yes' if musicgpt_api_key_set else '❌ No' }}</p>
|
|
<p><strong>Webhook URL:</strong> <span class="path">{{ musicgpt_webhook_url }}</span></p>
|
|
<p class="copy-hint">This URL is sent as <code>webhook_url</code> with every MusicGPT conversion request. MusicGPT POSTs results here when each task completes.</p>
|
|
|
|
<hr class="divider">
|
|
|
|
<h3>Album Cover Delivery</h3>
|
|
<p class="copy-hint">When enabled, the generated album cover is attached to the delivery email. It is always shown on this admin page.</p>
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="save_album_cover">
|
|
<label for="deliver_album_cover" class="checkbox-label">
|
|
<input type="checkbox" id="deliver_album_cover" name="deliver_album_cover" value="1" {% if deliver_album_cover %}checked{% endif %}>
|
|
Attach album cover to delivery email
|
|
</label>
|
|
<button type="submit">Save Album Cover Setting</button>
|
|
</form>
|
|
|
|
<hr class="divider">
|
|
|
|
<h3>Automatic MusicGPT Polling</h3>
|
|
<p class="copy-hint">When enabled, the site automatically polls MusicGPT every few minutes and downloads MP3/WAV files once they are ready.</p>
|
|
<form method="POST">
|
|
<input type="hidden" name="action" value="save_musicgpt_autopoll">
|
|
<label for="musicgpt_autopoll" class="checkbox-label">
|
|
<input type="checkbox" id="musicgpt_autopoll" name="musicgpt_autopoll" value="1" {% if musicgpt_autopoll %}checked{% endif %}>
|
|
Enable automatic MusicGPT polling
|
|
</label>
|
|
<button type="submit">Save Auto-Poll Setting</button>
|
|
</form>
|
|
</div>
|
|
|
|
<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 class="divider">
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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:var(--space-4)">
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
</body>
|
|
</html>
|