Files
apple-form/admin.html

489 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Form Admin</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0b0f1a;--card:#131827;--card2:#1a1f30;--fg:#e4e6ec;--fg2:#9399a8;
--blue:#0071e3;--blue-h:#0077ed;--green:#34c759;--red:#e03131;--yellow:#ffcc00;
--border:rgba(255,255,255,0.06);--radius:10px
}
body{
font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--fg);
min-height:100vh;line-height:1.5;-webkit-font-smoothing:antialiased
}
.login-screen{display:flex;align-items:center;justify-content:center;min-height:100vh}
.login-card{background:var(--card);padding:40px;border-radius:16px;width:100%;max-width:400px;
box-shadow:0 4px 24px rgba(0,0,0,0.3)}
.login-card h1{font-size:24px;font-weight:600;margin-bottom:8px}
.login-card p{color:var(--fg2);font-size:14px;margin-bottom:24px}
.app{max-width:1200px;margin:0 auto;padding:24px;display:none}
.nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;
padding-bottom:16px;border-bottom:1px solid var(--border)}
.nav h1{font-size:22px;font-weight:600}
.nav-tabs{display:flex;gap:4px}
.nav-tab{padding:8px 16px;border-radius:6px;cursor:pointer;font-size:14px;font-weight:500;
color:var(--fg2);border:none;background:none;transition:all .2s}
.nav-tab:hover{background:var(--card2);color:var(--fg)}
.nav-tab.active{background:var(--blue);color:#fff}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:24px}
.stat-card{background:var(--card);padding:20px;border-radius:var(--radius)}
.stat-label{font-size:13px;color:var(--fg2);margin-bottom:4px}
.stat-value{font-size:28px;font-weight:600}
.section{background:var(--card);border-radius:var(--radius);padding:24px;margin-bottom:24px}
.section h2{font-size:18px;font-weight:600;margin-bottom:16px}
.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
.btn{padding:10px 20px;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;
border:none;transition:all .15s;font-family:inherit}
.btn-primary{background:var(--blue);color:#fff}.btn-primary:hover{background:var(--blue-h)}
.btn-green{background:var(--green);color:#000}.btn-green:hover{opacity:.9}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--fg)}.btn-outline:hover{background:var(--card2)}
.btn-sm{padding:6px 12px;font-size:12px}
.btn-xs{padding:4px 8px;font-size:11px;border-radius:4px}
table{width:100%;border-collapse:collapse}
th,td{padding:12px;text-align:left;border-bottom:1px solid var(--border);font-size:14px}
th{color:var(--fg2);font-weight:500;font-size:13px;text-transform:uppercase;letter-spacing:.5px}
tr:hover{background:rgba(255,255,255,0.02)}
.tag{display:inline-block;padding:2px 8px;margin:2px;border-radius:4px;font-size:11px;font-weight:500;
background:rgba(0,113,227,0.15);color:var(--blue)}
.tag.green{background:rgba(52,199,89,0.15);color:var(--green)}
.reply-draft{font-size:13px;color:var(--fg2);margin-top:8px;padding:8px;background:var(--card2);border-radius:6px;max-width:400px}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:none;align-items:center;
justify-content:center;z-index:100;backdrop-filter:blur(4px)}
.modal-overlay.active{display:flex}
.modal{background:var(--card);border-radius:16px;padding:32px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto}
.modal h3{font-size:20px;margin-bottom:16px}
.input{width:100%;padding:12px;background:var(--card2);border:1px solid var(--border);border-radius:6px;
color:var(--fg);font-size:14px;font-family:inherit;outline:none}
.input:focus{border-color:var(--blue)}
.field-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
.field-row select,.field-row input{padding:8px;background:var(--card2);border:1px solid var(--border);
border-radius:4px;color:var(--fg);font-size:13px}
.field-row input{flex:1}
.field-row select{width:120px}
.mr-8{margin-right:8px}.mt-16{margin-top:16px}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:8px}
.flex{display:flex}.items-center{align-items:center}.gap-8{gap:8px}.justify-between{justify-content:space-between}
.text-sm{font-size:13px}.text-muted{color:var(--fg2)}.text-center{text-align:center}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--blue);
border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:8px}
@keyframes spin{to{transform:rotate(360deg)}}
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:8px;font-size:14px;
font-weight:500;z-index:200;animation:slideUp .3s ease;box-shadow:0 4px 20px rgba(0,0,0,0.4)}
.toast-success{background:var(--green);color:#000}
.toast-error{background:var(--red);color:#fff}
@keyframes slideUp{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
.empty-state{padding:48px;text-align:center;color:var(--fg2)}
.pulse{animation:pulse 2s infinite}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.tab-content{display:none}.tab-content.active{display:block}
</style>
</head>
<body>
<!-- Login Screen -->
<div class="login-screen" id="loginScreen">
<div class="login-card">
<h1>Form Admin</h1>
<p>Enter your admin password to continue.</p>
<input type="password" class="input" id="passInput" placeholder="Admin password" autofocus>
<button class="btn btn-primary mt-16" style="width:100%" onclick="doLogin()">Sign In</button>
<p class="text-sm text-muted text-center mt-16" id="loginMsg"></p>
</div>
</div>
<!-- App -->
<div class="app" id="app">
<nav class="nav">
<h1>Form Admin</h1>
<div class="nav-tabs">
<button class="nav-tab active" onclick="switchTab('dashboard')">Dashboard</button>
<button class="nav-tab" onclick="switchTab('builder')">Form Builder</button>
<button class="nav-tab" onclick="switchTab('settings')">Settings</button>
<button class="nav-tab" onclick="logout()" style="color:var(--red)">Logout</button>
</div>
</nav>
<!-- Dashboard Tab -->
<div class="tab-content active" id="tab-dashboard">
<div class="stats-grid" id="statsGrid"></div>
<div class="section">
<div class="section-header">
<h2>Submissions</h2>
<div class="flex gap-8">
<button class="btn btn-outline btn-sm" onclick="tagAll()">🏷 Tag All</button>
<button class="btn btn-sm btn-outline" onclick="loadSubmissions()">↻ Refresh</button>
</div>
</div>
<div id="subsTable"></div>
</div>
</div>
<!-- Builder Tab -->
<div class="tab-content" id="tab-builder">
<div class="section">
<div class="section-header">
<h2>Forms</h2>
<button class="btn btn-primary btn-sm" onclick="showNewForm()">+ New Form</button>
</div>
<div id="formsList"></div>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-content" id="tab-settings">
<div class="section" style="max-width:500px">
<h2>Settings</h2>
<div class="mb-16">
<label class="text-sm" style="display:block;margin-bottom:4px;color:var(--fg2)">DeepSeek API Key</label>
<input type="password" class="input" id="apiKeyInput" placeholder="sk-...">
<p class="text-sm text-muted">Current: <span id="apiKeyMask"></span></p>
</div>
<div class="mb-16">
<label class="text-sm" style="display:block;margin-bottom:4px;color:var(--fg2)">DeepSeek Model</label>
<input type="text" class="input" id="modelInput" placeholder="deepseek-chat">
</div>
<div class="mb-16">
<label class="text-sm" style="display:block;margin-bottom:4px;color:var(--fg2)">Admin Password</label>
<input type="password" class="input" id="adminPwInput" placeholder="New admin password">
</div>
<button class="btn btn-primary" onclick="saveSettings()">Save Settings</button>
<p class="text-sm text-muted mt-16" id="settingsMsg"></p>
</div>
</div>
</div>
<!-- Reply Modal -->
<div class="modal-overlay" id="replyModal">
<div class="modal">
<h3>Reply Draft</h3>
<div id="replyContent" class="mb-16"></div>
<button class="btn btn-outline btn-sm" onclick="closeModal('replyModal')">Close</button>
</div>
</div>
<!-- Form Editor Modal -->
<div class="modal-overlay" id="formModal">
<div class="modal">
<h3 id="formModalTitle">New Form</h3>
<div class="mb-8">
<label class="text-sm" style="color:var(--fg2)">Form Name</label>
<input type="text" class="input" id="formNameInput" placeholder="My Form">
</div>
<div class="mb-8">
<span class="text-sm" style="color:var(--fg2)">Fields</span>
<div id="formFieldsEditor" class="mt-8"></div>
<button class="btn btn-outline btn-sm mt-8" onclick="addFieldRow()">+ Add Field</button>
</div>
<div class="flex gap-8 mt-16">
<button class="btn btn-primary btn-sm" onclick="saveForm()">Save Form</button>
<button class="btn btn-outline btn-sm" onclick="closeModal('formModal')">Cancel</button>
</div>
</div>
</div>
<script>
const API = '';
let token = '';
// ---- Auth ----
async function doLogin(){
const pw = document.getElementById('passInput').value;
const msg = document.getElementById('loginMsg');
msg.textContent = '';
try{
const r = await fetch(API+'/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})});
if(!r.ok){msg.textContent='Wrong password';return}
const d = await r.json();
token = d.token;
localStorage.setItem('form_admin_token', token);
document.getElementById('loginScreen').style.display='none';
document.getElementById('app').style.display='block';
initApp();
}catch(e){msg.textContent='Connection error'}
}
function logout(){
fetch(API+'/admin/logout',{method:'POST',headers:{Authorization:'Bearer '+token}}).catch(()=>{});
localStorage.removeItem('form_admin_token');
token='';
document.getElementById('loginScreen').style.display='flex';
document.getElementById('app').style.display='none';
}
async function api(method, path, body=null){
const opts = {method,headers:{Authorization:'Bearer '+token}};
if(body){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}
const r = await fetch(API+path, opts);
if(r.status===401){logout();throw new Error('Unauthorized')}
if(!r.ok){const e = await r.json().catch(()=>({}));throw new Error(e.detail||e.error||'Request failed')}
return r.json();
}
// ---- Toast ----
function toast(msg, type='success'){
const t = document.createElement('div');
t.className = 'toast toast-'+type;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(()=>{t.style.opacity='0';setTimeout(()=>t.remove(),300)},2500);
}
// ---- Tabs ----
function switchTab(name){
document.querySelectorAll('.nav-tab').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-'+name).classList.add('active');
if(name==='dashboard'){loadStats();loadSubmissions()}
if(name==='builder')loadForms();
if(name==='settings')loadSettings();
}
// ---- Modal ----
function closeModal(id){document.getElementById(id).classList.remove('active')}
// ---- Stats ----
async function loadStats(){
try{
const s = await api('GET','/admin/stats');
document.getElementById('statsGrid').innerHTML = `
<div class="stat-card"><div class="stat-label">Total Submissions</div><div class="stat-value">${s.total_submissions}</div></div>
<div class="stat-card"><div class="stat-label">Today</div><div class="stat-value">${s.today}</div></div>
<div class="stat-card"><div class="stat-label">Forms</div><div class="stat-value">${s.forms}</div></div>
<div class="stat-card"><div class="stat-label">Tagged</div><div class="stat-value">${s.tagged}</div></div>`;
}catch(e){}
}
// ---- Submissions ----
async function loadSubmissions(){
try{
const data = await api('GET','/admin/submissions?per_page=100');
const subs = data.submissions;
if(!subs.length){
document.getElementById('subsTable').innerHTML = '<div class="empty-state">No submissions yet.</div>';
return;
}
let html = '<table><thead><tr><th>ID</th><th>Data</th><th>Form</th><th>Tags</th><th>Reply</th><th>Actions</th></tr></thead><tbody>';
for(const s of subs){
const fields = s.data.map(d=>`<b>${d.field_label}:</b> ${d.field_value}`).join(' &middot; ');
const tags = s.tags.map(t=>`<span class="tag">${t}</span>`).join(' ') || '<span class="text-muted text-sm">—</span>';
const replyHtml = s.reply
? `<div class="reply-draft">${escapeHtml(s.reply.draft_text)}</div>`
: '<span class="text-muted text-sm">—</span>';
html += `<tr>
<td>#${s.id}</td>
<td style="max-width:300px;word-break:break-word">${fields}</td>
<td>${escapeHtml(s.form_name)}</td>
<td>${tags}</td>
<td>${replyHtml}</td>
<td>
<button class="btn btn-outline btn-xs mr-8" onclick="genReply(${s.id})">✉ Reply</button>
<button class="btn btn-outline btn-xs" onclick="tagOne(${s.id})">🏷 Tag</button>
</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('subsTable').innerHTML = html;
}catch(e){document.getElementById('subsTable').innerHTML='<div class="empty-state">Error loading.</div>'}
}
function escapeHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
// ---- DeepSeek actions ----
async function tagAll(){
if(!confirm('Tag all submissions with DeepSeek?'))return;
toast('Analyzing submissions...', 'success');
try{
const r = await api('POST','/admin/tag',{all:true});
toast(`Tagged ${r.tagged} submissions!`, 'success');
loadSubmissions();loadStats();
}catch(e){toast(e.message,'error')}
}
async function tagOne(id){
toast('Analyzing...','success');
try{
const r = await api('POST','/admin/tag',{submission_ids:[id]});
toast(`Tagged!`, 'success');
loadSubmissions();loadStats();
}catch(e){toast(e.message,'error')}
}
async function genReply(id){
toast('Generating reply...','success');
try{
const r = await api('POST','/admin/reply/'+id);
document.getElementById('replyContent').innerHTML = `<pre style="white-space:pre-wrap;font-family:inherit;font-size:14px;line-height:1.6;color:var(--fg)">${escapeHtml(r.draft)}</pre>`;
document.getElementById('replyModal').classList.add('active');
loadSubmissions();
}catch(e){toast(e.message,'error')}
}
// ---- Forms / Builder ----
async function loadForms(){
try{
const forms = await api('GET','/forms');
let html='';
for(const f of forms){
const fieldList = f.fields.map(ff=>`${ff.label} (${ff.field_type}${ff.required?'*':''})`).join(', ');
html += `<div class="section mb-16" style="padding:16px">
<div class="flex justify-between items-center mb-8">
<div><b>${escapeHtml(f.name)}</b> <span class="text-sm text-muted">/${f.slug}</span></div>
<div class="flex gap-8">
<button class="btn btn-outline btn-xs" onclick="editForm(${f.id})">Edit</button>
${f.id!==1?`<button class="btn btn-outline btn-xs" onclick="delForm(${f.id})" style="color:var(--red)">Delete</button>`:''}
</div>
</div>
<div class="text-sm text-muted">${fieldList || 'No fields'}</div>
<div class="text-sm text-muted mt-8">📋 <code>http://192.168.1.121:8080/submit/${f.slug}</code></div>
</div>`;
}
document.getElementById('formsList').innerHTML = html;
}catch(e){}
}
let editingFormId = null;
function showNewForm(){
editingFormId = null;
document.getElementById('formModalTitle').textContent = 'New Form';
document.getElementById('formNameInput').value = '';
document.getElementById('formFieldsEditor').innerHTML = '';
addFieldRow();addFieldRow();
document.getElementById('formModal').classList.add('active');
}
async function editForm(id){
editingFormId = id;
try{
const forms = await api('GET','/forms');
const f = forms.find(x=>x.id===id);
if(!f)return;
document.getElementById('formModalTitle').textContent = 'Edit: '+f.name;
document.getElementById('formNameInput').value = f.name;
const editor = document.getElementById('formFieldsEditor');
editor.innerHTML = '';
for(const ff of f.fields){
addFieldRow(ff.label, ff.field_type, ff.required);
}
if(!f.fields.length){addFieldRow();addFieldRow()}
document.getElementById('formModal').classList.add('active');
}catch(e){toast('Error loading form','error')}
}
function addFieldRow(label='', type='text', required='1'){
const req = String(required) === '1';
const div = document.createElement('div');
div.className = 'field-row';
div.innerHTML = `
<input type="text" placeholder="Label" value="${escapeHtml(label)}" class="field-label">
<select class="field-type">
<option value="text" ${type==='text'?'selected':''}>Text</option>
<option value="email" ${type==='email'?'selected':''}>Email</option>
<option value="number" ${type==='number'?'selected':''}>Number</option>
<option value="tel" ${type==='tel'?'selected':''}>Phone</option>
<option value="url" ${type==='url'?'selected':''}>URL</option>
<option value="textarea" ${type==='textarea'?'selected':''}>Long Text</option>
</select>
<label style="font-size:12px;color:var(--fg2);white-space:nowrap">
<input type="checkbox" class="field-required" ${req?'checked':''}> Required
</label>
<button class="btn btn-outline btn-xs" onclick="this.parentElement.remove()" style="color:var(--red)">✕</button>`;
document.getElementById('formFieldsEditor').appendChild(div);
}
async function saveForm(){
const name = document.getElementById('formNameInput').value.trim();
if(!name){toast('Form name required','error');return}
const rows = document.querySelectorAll('#formFieldsEditor .field-row');
const fields = [];
rows.forEach(r=>{
const label = r.querySelector('.field-label').value.trim();
if(!label)return;
fields.push({
label,
field_type: r.querySelector('.field-type').value,
required: r.querySelector('.field-required').checked
});
});
if(!fields.length){toast('Add at least one field','error');return}
try{
if(editingFormId){
await api('PUT','/forms/'+editingFormId,{name,fields});
toast('Form updated!');
}else{
await api('POST','/forms',{name,fields});
toast('Form created!');
}
closeModal('formModal');
loadForms();
}catch(e){toast(e.message,'error')}
}
async function delForm(id){
if(!confirm('Delete this form and all its submissions?'))return;
try{
await api('DELETE','/forms/'+id);
toast('Form deleted');
loadForms();
}catch(e){toast(e.message,'error')}
}
// ---- Settings ----
async function loadSettings(){
try{
const s = await api('GET','/admin/settings');
document.getElementById('apiKeyInput').placeholder = s.deepseek_api_key ? '(set — leave blank to keep)' : 'sk-...';
document.getElementById('apiKeyMask').textContent = s.deepseek_api_key_masked || '—';
document.getElementById('modelInput').value = s.deepseek_model || 'deepseek-chat';
document.getElementById('adminPwInput').placeholder = '(set to change)';
}catch(e){}
}
async function saveSettings(){
const body = {};
const ak = document.getElementById('apiKeyInput').value.trim();
if(ak){body.deepseek_api_key = ak}
const model = document.getElementById('modelInput').value.trim();
if(model){body.deepseek_model = model}
const pw = document.getElementById('adminPwInput').value.trim();
if(pw){body.admin_password = pw}
if(!Object.keys(body).length){toast('No changes','error');return}
try{
await api('POST','/admin/settings',body);
document.getElementById('apiKeyInput').value = '';
document.getElementById('adminPwInput').value = '';
document.getElementById('settingsMsg').textContent = 'Saved!';
toast('Settings saved');
loadSettings();
}catch(e){toast(e.message,'error')}
}
// ---- Init ----
function initApp(){
loadStats();
loadSubmissions();
}
// Auto-login with saved token
(async function(){
const saved = localStorage.getItem('form_admin_token');
if(saved){
token = saved;
try{
await api('GET','/admin/stats');
document.getElementById('loginScreen').style.display='none';
document.getElementById('app').style.display='block';
initApp();
}catch(e){localStorage.removeItem('form_admin_token');token=''}
}
})();
// Enter key for login
document.getElementById('passInput').addEventListener('keydown',function(e){if(e.key==='Enter')doLogin()});
</script>
</body>
</html>