Initial fork from theme-song-booth
This commit is contained in:
commit
99855b1212
33 changed files with 5723 additions and 0 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue